The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
├── .github
    └── workflows
    │   └── release.yml
├── .gitignore
├── .goreleaser.yaml
├── DISCLAIMER.md
├── LICENSE
├── Makefile
├── README.md
├── cmd
    └── chatlog
    │   ├── cmd_decrypt.go
    │   ├── cmd_dumpmemory.go
    │   ├── cmd_key.go
    │   ├── cmd_server.go
    │   ├── cmd_version.go
    │   ├── log.go
    │   └── root.go
├── docs
    ├── mcp.md
    └── prompt.md
├── go.mod
├── go.sum
├── internal
    ├── chatlog
    │   ├── app.go
    │   ├── conf
    │   │   ├── config.go
    │   │   └── service.go
    │   ├── ctx
    │   │   └── context.go
    │   ├── database
    │   │   └── service.go
    │   ├── http
    │   │   ├── route.go
    │   │   ├── service.go
    │   │   └── static
    │   │   │   └── index.htm
    │   ├── manager.go
    │   ├── mcp
    │   │   ├── const.go
    │   │   └── service.go
    │   └── wechat
    │   │   └── service.go
    ├── errors
    │   ├── errors.go
    │   ├── http_errors.go
    │   ├── middleware.go
    │   ├── os_errors.go
    │   ├── wechat_errors.go
    │   └── wechatdb_errors.go
    ├── mcp
    │   ├── error.go
    │   ├── initialize.go
    │   ├── jsonrpc.go
    │   ├── mcp.go
    │   ├── prompt.go
    │   ├── resource.go
    │   ├── session.go
    │   ├── sse.go
    │   ├── stdio.go
    │   └── tool.go
    ├── model
    │   ├── chatroom.go
    │   ├── chatroom_darwinv3.go
    │   ├── chatroom_v4.go
    │   ├── contact.go
    │   ├── contact_darwinv3.go
    │   ├── contact_v4.go
    │   ├── media.go
    │   ├── media_darwinv3.go
    │   ├── media_v4.go
    │   ├── mediamessage.go
    │   ├── message.go
    │   ├── message_darwinv3.go
    │   ├── message_v3.go
    │   ├── message_v4.go
    │   ├── session.go
    │   ├── session_darwinv3.go
    │   ├── session_v4.go
    │   └── wxproto
    │   │   ├── bytesextra.pb.go
    │   │   ├── bytesextra.proto
    │   │   ├── packedinfo.pb.go
    │   │   ├── packedinfo.proto
    │   │   ├── roomdata.pb.go
    │   │   └── roomdata.proto
    ├── ui
    │   ├── footer
    │   │   └── footer.go
    │   ├── form
    │   │   └── form.go
    │   ├── help
    │   │   └── help.go
    │   ├── infobar
    │   │   └── infobar.go
    │   ├── menu
    │   │   ├── menu.go
    │   │   └── submenu.go
    │   └── style
    │   │   ├── style.go
    │   │   └── style_windows.go
    ├── wechat
    │   ├── decrypt
    │   │   ├── common
    │   │   │   └── common.go
    │   │   ├── darwin
    │   │   │   ├── v3.go
    │   │   │   └── v4.go
    │   │   ├── decryptor.go
    │   │   ├── validator.go
    │   │   └── windows
    │   │   │   ├── v3.go
    │   │   │   └── v4.go
    │   ├── key
    │   │   ├── darwin
    │   │   │   ├── glance
    │   │   │   │   ├── glance.go
    │   │   │   │   ├── sip.go
    │   │   │   │   └── vmmap.go
    │   │   │   ├── v3.go
    │   │   │   └── v4.go
    │   │   ├── extractor.go
    │   │   └── windows
    │   │   │   ├── v3.go
    │   │   │   ├── v3_others.go
    │   │   │   ├── v3_windows.go
    │   │   │   ├── v4.go
    │   │   │   ├── v4_others.go
    │   │   │   └── v4_windows.go
    │   ├── manager.go
    │   ├── model
    │   │   └── process.go
    │   ├── process
    │   │   ├── darwin
    │   │   │   └── detector.go
    │   │   ├── detector.go
    │   │   └── windows
    │   │   │   ├── detector.go
    │   │   │   ├── detector_others.go
    │   │   │   └── detector_windows.go
    │   └── wechat.go
    └── wechatdb
    │   ├── datasource
    │       ├── darwinv3
    │       │   └── datasource.go
    │       ├── datasource.go
    │       ├── dbm
    │       │   ├── dbm.go
    │       │   ├── dbm_test.go
    │       │   └── group.go
    │       ├── v4
    │       │   └── datasource.go
    │       └── windowsv3
    │       │   └── datasource.go
    │   ├── repository
    │       ├── chatroom.go
    │       ├── contact.go
    │       ├── media.go
    │       ├── message.go
    │       ├── repository.go
    │       └── session.go
    │   └── wechatdb.go
├── main.go
├── pkg
    ├── appver
    │   ├── version.go
    │   ├── version_darwin.go
    │   ├── version_others.go
    │   └── version_windows.go
    ├── config
    │   ├── config.go
    │   └── default.go
    ├── filecopy
    │   └── filecopy.go
    ├── filemonitor
    │   ├── filegroup.go
    │   └── filemonitor.go
    ├── util
    │   ├── dat2img
    │   │   └── dat2img.go
    │   ├── lz4
    │   │   └── lz4.go
    │   ├── os.go
    │   ├── os_windows.go
    │   ├── silk
    │   │   └── silk.go
    │   ├── strings.go
    │   ├── time.go
    │   ├── time_test.go
    │   └── zstd
    │   │   └── zstd.go
    └── version
    │   └── version.go
└── script
    └── package.sh


/.github/workflows/release.yml:
--------------------------------------------------------------------------------
 1 | name: Release
 2 | 
 3 | on:
 4 |   push:
 5 |     tags:
 6 |       - 'v*'
 7 | 
 8 | env:
 9 |   ENABLE_UPX: 1
10 | 
11 | jobs:
12 |   release:
13 |     name: Release Binary
14 |     runs-on: ubuntu-latest
15 |     container:
16 |       image: goreleaser/goreleaser-cross:v1.24
17 |     steps:
18 |       - name: Checkout
19 |         uses: actions/checkout@v4
20 |         with:
21 |           fetch-depth: 0
22 |       - run: git config --global --add safe.directory "$(pwd)"
23 | 
24 |       - name: Setup Go
25 |         uses: actions/setup-go@v4
26 |         with:
27 |           go-version: '^1.24'
28 | 
29 |       - name: Cache go module
30 |         uses: actions/cache@v3
31 |         with:
32 |           path: ~/go/pkg/mod
33 |           key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
34 |           restore-keys: |
35 |             ${{ runner.os }}-go-
36 | 
37 |       - name: Install UPX
38 |         uses: crazy-max/ghaction-upx@v3
39 |         with:
40 |           install-only: true
41 | 
42 |       - name: Run GoReleaser
43 |         run: goreleaser release --clean
44 |         env:
45 |           GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
46 |           ENABLE_UPX: true
47 | 


--------------------------------------------------------------------------------
/.gitignore:
--------------------------------------------------------------------------------
 1 | # If you prefer the allow list template instead of the deny list, see community template:
 2 | # https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
 3 | #
 4 | # Binaries for programs and plugins
 5 | *.exe
 6 | *.exe~
 7 | *.dll
 8 | *.so
 9 | *.dylib
10 | 
11 | # Test binary, built with `go test -c`
12 | *.test
13 | 
14 | # Output of the go coverage tool, specifically when used with LiteIDE
15 | *.out
16 | 
17 | # Dependency directories (remove the comment below to include it)
18 | # vendor/
19 | 
20 | # Go workspace file
21 | go.work
22 | go.work.sum
23 | 
24 | # env file
25 | .env
26 | 
27 | # syncthing files
28 | .stfolder
29 | 
30 | chatlog.exe# Added by goreleaser init:
31 | dist/
32 | 


--------------------------------------------------------------------------------
/.goreleaser.yaml:
--------------------------------------------------------------------------------
 1 | # GoReleaser v2 配置
 2 | version: 2
 3 | 
 4 | before:
 5 |   hooks:
 6 |     - go mod tidy
 7 | 
 8 | builds:
 9 |   - id: darwin-amd64
10 |     binary: chatlog
11 |     env:
12 |       - CGO_ENABLED=1
13 |       - CC=o64-clang
14 |       - CXX=o64-clang++
15 |     goos:
16 |       - darwin
17 |     goarch:
18 |       - amd64
19 |     ldflags:
20 |       - -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
21 | 
22 |   - id: darwin-arm64
23 |     binary: chatlog
24 |     env:
25 |       - CGO_ENABLED=1
26 |       - CC=oa64-clang
27 |       - CXX=oa64-clang++
28 |     goos:
29 |       - darwin
30 |     goarch:
31 |       - arm64
32 |     ldflags:
33 |       - -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
34 | 
35 |   - id: windows-amd64
36 |     binary: chatlog
37 |     env:
38 |       - CGO_ENABLED=1
39 |       - CC=x86_64-w64-mingw32-gcc
40 |       - CXX=x86_64-w64-mingw32-g++
41 |     goos:
42 |       - windows
43 |     goarch:
44 |       - amd64
45 |     ldflags:
46 |       - -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
47 | 
48 |   - id: windows-arm64
49 |     binary: chatlog
50 |     env:
51 |       - CGO_ENABLED=1
52 |       - CC=/llvm-mingw/bin/aarch64-w64-mingw32-gcc
53 |       - CXX=/llvm-mingw/bin/aarch64-w64-mingw32-g++
54 |     goos:
55 |       - windows
56 |     goarch:
57 |       - arm64
58 |     ldflags:
59 |       - -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
60 | 
61 | archives:
62 |   - id: default
63 |     format: tar.gz
64 |     name_template: >-
65 |       {{ .ProjectName }}_
66 |       {{- .Version }}_
67 |       {{- .Os }}_
68 |       {{- .Arch }}
69 |     format_overrides:
70 |       - goos: windows
71 |         format: zip
72 |     files:
73 |       - LICENSE
74 |       - README.md
75 | 
76 | upx:
77 |   - enabled: "{{ .Env.ENABLE_UPX }}"
78 |     goos: [darwin, windows]
79 |     goarch: [amd64]
80 |     compress: best
81 | 
82 | checksum:
83 |   name_template: 'checksums.txt'
84 |   algorithm: sha256
85 | 
86 | # 配置 GitHub Release
87 | release:
88 |   draft: true
89 |   prerelease: auto
90 |   mode: replace
91 | 


--------------------------------------------------------------------------------
/DISCLAIMER.md:
--------------------------------------------------------------------------------
  1 | # Chatlog 免责声明
  2 | 
  3 | ## 1. 定义
  4 | 
  5 | 在本免责声明中,除非上下文另有说明,下列术语应具有以下含义:
  6 | 
  7 | - **"本项目"或"Chatlog"**:指本开源软件项目,包括其源代码、可执行程序、文档及相关资源。
  8 | - **"开发者"**:指本项目的创建者、维护者及代码贡献者。
  9 | - **"用户"**:指下载、安装、使用或以任何方式接触本项目的个人或实体。
 10 | - **"聊天数据"**:指通过各类即时通讯软件生成的对话内容及相关元数据。
 11 | - **"合法授权"**:指根据适用法律法规,由数据所有者或数据主体明确授予的处理其聊天数据的权限。
 12 | - **"第三方服务"**:指由非本项目开发者提供的外部服务,如大型语言模型(LLM) API 服务。
 13 | 
 14 | ## 2. 使用目的与法律遵守
 15 | 
 16 | 本项目仅供学习、研究和个人合法使用。用户须严格遵守所在国家/地区的法律法规使用本工具。任何违反法律法规、侵犯他人合法权益的行为,均与本项目及其开发者无关,相关法律责任由用户自行承担。
 17 | 
 18 | ⚠️ **用户应自行了解并遵守当地有关数据访问、隐私保护、计算机安全和网络安全的法律法规。不同司法管辖区对数据处理有不同的法律要求,用户有责任确保其使用行为符合所有适用法规。**
 19 | 
 20 | ## 3. 授权范围与隐私保护
 21 | 
 22 | - 本工具仅限于处理用户自己合法拥有的聊天数据,或已获得数据所有者明确授权的数据。
 23 | - 严禁将本工具用于未经授权获取、查看或分析他人聊天记录,或侵犯他人隐私权。
 24 | - 用户应采取适当措施保护通过本工具获取和处理的聊天数据安全,包括但不限于加密存储、限制访问权限、定期删除不必要数据等。
 25 | - 用户应确保其处理的聊天数据符合相关数据保护法规,包括但不限于获得必要的同意、保障数据主体权利、遵守数据最小化原则等。
 26 | 
 27 | ## 4. 使用限制
 28 | 
 29 | - 本项目仅允许在合法授权情况下对聊天数据库进行备份与查看。
 30 | - 未经明确授权,严禁将本项目用于访问、查看、分析或处理任何第三方聊天数据。
 31 | - 使用第三方 LLM 服务时,用户应遵守相关服务提供商的服务条款和使用政策。
 32 | - 用户不得规避本项目中的任何技术限制,或尝试反向工程、反编译或反汇编本项目,除非适用法律明确允许此类活动。
 33 | 
 34 | ## 5. 技术风险声明
 35 | 
 36 | ⚠️ **使用本项目存在以下技术风险,用户应充分了解并自行承担:**
 37 | 
 38 | - 本工具需要访问聊天软件的数据库文件,可能因聊天软件版本更新导致功能失效或数据不兼容。
 39 | - 在 macOS 系统上使用时,需要临时关闭 SIP 安全机制,这可能降低系统安全性,用户应了解相关风险并自行决定是否使用。
 40 | - 本项目可能存在未知的技术缺陷或安全漏洞,可能导致数据损坏、丢失或泄露。
 41 | - 使用本项目处理大量数据可能导致系统性能下降或资源占用过高。
 42 | - 第三方依赖库或 API 的变更可能影响本项目的功能或安全性。
 43 | 
 44 | ## 6. 禁止非法用途
 45 | 
 46 | 严禁将本项目用于以下用途:
 47 | 
 48 | - 从事任何形式的非法活动,包括但不限于未授权系统测试、网络渗透或其他违反法律法规的行为。
 49 | - 监控、窃取或未经授权获取他人聊天记录或个人信息。
 50 | - 将获取的数据用于骚扰、诈骗、敲诈、威胁或其他侵害他人合法权益的行为。
 51 | - 规避任何安全措施或访问控制机制。
 52 | - 传播虚假信息、仇恨言论或违反公序良俗的内容。
 53 | - 侵犯任何第三方的知识产权、隐私权或其他合法权益。
 54 | 
 55 | **违反上述规定的,用户应自行承担全部法律责任,并赔偿因此给开发者或第三方造成的全部损失。**
 56 | 
 57 | ## 7. 第三方服务集成
 58 | 
 59 | - 用户将聊天数据与第三方 LLM 服务(如 OpenAI、Claude 等)结合使用时,应仔细阅读并遵守这些服务的使用条款、隐私政策和数据处理协议。
 60 | - 用户应了解,向第三方服务传输数据可能导致数据离开用户控制范围,并受第三方服务条款约束。
 61 | - 本项目开发者不对第三方服务的可用性、安全性、准确性或数据处理行为负责,用户应自行评估相关风险。
 62 | - 用户应确保其向第三方服务传输数据的行为符合适用的数据保护法规和第三方服务条款。
 63 | 
 64 | ## 8. 责任限制
 65 | 
 66 | **在法律允许的最大范围内:**
 67 | 
 68 | - 本项目按"原样"和"可用"状态提供,不对功能的适用性、可靠性、准确性、完整性或及时性做任何明示或暗示的保证。
 69 | - 开发者明确否认对适销性、特定用途适用性、不侵权以及任何其他明示或暗示的保证。
 70 | - 本项目开发者和贡献者不对用户使用本工具的行为及后果承担任何法律责任。
 71 | - 对于因使用本工具而可能导致的任何直接、间接、附带、特殊、惩罚性或后果性损失,包括但不限于数据丢失、业务中断、隐私泄露、声誉损害、利润损失、法律纠纷等,本项目开发者概不负责,即使开发者已被告知此类损失的可能性。
 72 | - 在任何情况下,开发者对用户的全部责任累计不超过用户为获取本软件实际支付的金额(如为免费获取则为零)。
 73 | 
 74 | ## 9. 知识产权声明
 75 | 
 76 | - 本项目基于 Apache-2.0 许可证开源,用户在使用、修改和分发时应严格遵守该许可证的所有条款。
 77 | - 本项目的名称"Chatlog"、相关标识及商标权(如有)归开发者所有,未经明确授权,用户不得以任何方式使用这些标识进行商业活动。
 78 | - 根据 Apache-2.0 许可证,用户可自由使用、修改和分发本项目代码,但须遵守许可证规定的归属声明等要求。
 79 | - 用户对其修改版本自行承担全部责任,且不得以原项目名义发布,必须明确标明其为修改版本并与原项目区分。
 80 | - 用户不得移除或更改本项目中的版权声明、商标或其他所有权声明。
 81 | 
 82 | ## 10. 数据处理合规性
 83 | 
 84 | - 用户在使用本项目处理个人数据时,应遵守适用的数据保护法规,包括但不限于《中华人民共和国个人信息保护法》、《通用数据保护条例》(GDPR)等。
 85 | - 用户应确保其具有处理相关数据的合法依据,如获得数据主体的明确同意。
 86 | - 用户应实施适当的技术和组织措施,确保数据安全,防止未授权访问、意外丢失或泄露。
 87 | - 在跨境传输数据时,用户应确保符合相关法律对数据出境的要求。
 88 | - 用户应尊重数据主体权利,包括访问权、更正权、删除权等。
 89 | 
 90 | ## 11. 免责声明接受
 91 | 
 92 | 下载、安装、使用本项目,表示用户已阅读、理解并同意遵守本免责声明的所有条款。如不同意,请立即停止使用本工具并删除相关代码和程序。
 93 | 
 94 | **用户确认:**
 95 | - 已完整阅读并理解本免责声明的全部内容
 96 | - 自愿接受本免责声明的全部条款
 97 | - 具有完全民事行为能力,能够理解并承担使用本项目的风险和责任
 98 | - 将遵守本免责声明中规定的所有义务和限制
 99 | 
100 | ## 12. 免责声明修改与通知
101 | 
102 | - 本免责声明可能根据项目发展和法律法规变化进行修改和调整,修改后的声明将在项目官方仓库页面公布。
103 | - 开发者没有义务个别通知用户免责声明的变更,用户应定期查阅最新版本。
104 | - 重大变更将通过项目仓库的 Release Notes 或 README 文件更新进行通知。
105 | - 在免责声明更新后继续使用本项目,即视为接受修改后的条款。
106 | 
107 | ## 13. 法律适用与管辖
108 | 
109 | - 本免责声明受中华人民共和国法律管辖,并按其解释。
110 | - 任何与本免责声明有关的争议,应首先通过友好协商解决;协商不成的,提交至本项目开发者所在地有管辖权的人民法院诉讼解决。
111 | - 对于中国境外用户,如本免责声明与用户所在地强制性法律规定冲突,应以不违反该强制性规定的方式解释和适用本声明,但本声明的其余部分仍然有效。
112 | 
113 | ## 14. 可分割性
114 | 
115 | 如本免责声明中的任何条款被有管辖权的法院或其他权威机构认定为无效、不合法或不可执行,不影响其余条款的有效性和可执行性。无效条款应被视为从本声明中分割,并在法律允许的最大范围内由最接近原条款意图的有效条款替代。
116 | 
117 | ## 15. 完整协议
118 | 
119 | 本免责声明构成用户与开发者之间关于本项目使用的完整协议,取代先前或同时期关于本项目的所有口头或书面协议、提议和陈述。本声明的任何豁免、修改或补充均应以书面形式作出并经开发者签署方为有效。
120 | 
121 | 
122 | 


--------------------------------------------------------------------------------
/Makefile:
--------------------------------------------------------------------------------
 1 | BINARY_NAME := chatlog
 2 | GO := go
 3 | ifeq ($(VERSION),)
 4 | 	VERSION := $(shell git describe --tags --always --dirty="-dev")
 5 | endif
 6 | LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)" -w -s'
 7 | 
 8 | PLATFORMS := \
 9 | 	darwin/amd64 \
10 | 	darwin/arm64 \
11 | 	windows/amd64 \
12 | 	windows/arm64
13 | 
14 | UPX_PLATFORMS := \
15 | 	darwin/amd64 \
16 | 	windows/386 \
17 | 	windows/amd64
18 | 
19 | .PHONY: all clean lint tidy test build crossbuild upx
20 | 
21 | all: clean lint tidy test build
22 | 
23 | clean:
24 | 	@echo "🧹 Cleaning..."
25 | 	@rm -rf bin/
26 | 
27 | lint:
28 | 	@echo "🕵️‍♂️ Running linters..."
29 | 	golangci-lint run ./...
30 | 
31 | tidy:
32 | 	@echo "🧼 Tidying up dependencies..."
33 | 	$(GO) mod tidy
34 | 
35 | test:
36 | 	@echo "🧪 Running tests..."
37 | 	$(GO) test ./... -cover
38 | 
39 | build:
40 | 	@echo "🔨 Building for current platform..."
41 | 	CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME) main.go
42 | 
43 | crossbuild: clean
44 | 	@echo "🌍 Building for multiple platforms..."
45 | 	for platform in $(PLATFORMS); do \
46 | 		os=$(echo $platform | cut -d/ -f1); \
47 | 		arch=$(echo $platform | cut -d/ -f2); \
48 | 		float=$(echo $platform | cut -d/ -f3); \
49 | 		output_name=bin/chatlog_${os}_${arch}; \
50 | 		[ "$float" != "" ] && output_name=$output_name_$float; \
51 | 		echo "🔨 Building for $os/$arch..."; \
52 | 		echo "🔨 Building for $output_name..."; \
53 | 		GOOS=$os GOARCH=$arch CGO_ENABLED=1 GOARM=$float $(GO) build -trimpath $(LDFLAGS) -o $output_name main.go ; \
54 | 		if [ "$(ENABLE_UPX)" = "1" ] && echo "$(UPX_PLATFORMS)" | grep -q "$os/$arch"; then \
55 | 			echo "⚙️ Compressing binary $output_name..." && upx --best $output_name; \
56 | 		fi; \
57 | 	done


--------------------------------------------------------------------------------
/cmd/chatlog/cmd_decrypt.go:
--------------------------------------------------------------------------------
 1 | package chatlog
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 	"runtime"
 6 | 
 7 | 	"github.com/sjzar/chatlog/internal/chatlog"
 8 | 
 9 | 	"github.com/rs/zerolog/log"
10 | 	"github.com/spf13/cobra"
11 | )
12 | 
13 | func init() {
14 | 	rootCmd.AddCommand(decryptCmd)
15 | 	decryptCmd.Flags().StringVarP(&dataDir, "data-dir", "d", "", "data dir")
16 | 	decryptCmd.Flags().StringVarP(&workDir, "work-dir", "w", "", "work dir")
17 | 	decryptCmd.Flags().StringVarP(&key, "key", "k", "", "key")
18 | 	decryptCmd.Flags().StringVarP(&decryptPlatform, "platform", "p", runtime.GOOS, "platform")
19 | 	decryptCmd.Flags().IntVarP(&decryptVer, "version", "v", 3, "version")
20 | }
21 | 
22 | var (
23 | 	dataDir         string
24 | 	workDir         string
25 | 	key             string
26 | 	decryptPlatform string
27 | 	decryptVer      int
28 | )
29 | 
30 | var decryptCmd = &cobra.Command{
31 | 	Use:   "decrypt",
32 | 	Short: "decrypt",
33 | 	Run: func(cmd *cobra.Command, args []string) {
34 | 		m, err := chatlog.New("")
35 | 		if err != nil {
36 | 			log.Err(err).Msg("failed to create chatlog instance")
37 | 			return
38 | 		}
39 | 		if err := m.CommandDecrypt(dataDir, workDir, key, decryptPlatform, decryptVer); err != nil {
40 | 			log.Err(err).Msg("failed to decrypt")
41 | 			return
42 | 		}
43 | 		fmt.Println("decrypt success")
44 | 	},
45 | }
46 | 


--------------------------------------------------------------------------------
/cmd/chatlog/cmd_dumpmemory.go:
--------------------------------------------------------------------------------
  1 | package chatlog
  2 | 
  3 | import (
  4 | 	"archive/zip"
  5 | 	"fmt"
  6 | 	"io"
  7 | 	"os"
  8 | 	"path/filepath"
  9 | 	"runtime"
 10 | 	"time"
 11 | 
 12 | 	"github.com/rs/zerolog/log"
 13 | 	"github.com/spf13/cobra"
 14 | 
 15 | 	"github.com/sjzar/chatlog/internal/wechat"
 16 | 	"github.com/sjzar/chatlog/internal/wechat/key/darwin/glance"
 17 | )
 18 | 
 19 | func init() {
 20 | 	rootCmd.AddCommand(dumpmemoryCmd)
 21 | }
 22 | 
 23 | var dumpmemoryCmd = &cobra.Command{
 24 | 	Use:   "dumpmemory",
 25 | 	Short: "dump memory",
 26 | 	Run: func(cmd *cobra.Command, args []string) {
 27 | 		if runtime.GOOS != "darwin" {
 28 | 			log.Info().Msg("dump memory only support macOS")
 29 | 		}
 30 | 
 31 | 		session := time.Now().Format("20060102150405")
 32 | 
 33 | 		dir, err := os.Getwd()
 34 | 		if err != nil {
 35 | 			log.Fatal().Err(err).Msg("get current directory failed")
 36 | 			return
 37 | 		}
 38 | 		log.Info().Msgf("current directory: %s", dir)
 39 | 
 40 | 		// step 1. check pid
 41 | 		if err = wechat.Load(); err != nil {
 42 | 			log.Fatal().Err(err).Msg("load wechat failed")
 43 | 			return
 44 | 		}
 45 | 		accounts := wechat.GetAccounts()
 46 | 		if len(accounts) == 0 {
 47 | 			log.Fatal().Msg("no wechat account found")
 48 | 			return
 49 | 		}
 50 | 
 51 | 		log.Info().Msgf("found %d wechat account", len(accounts))
 52 | 		for i, a := range accounts {
 53 | 			log.Info().Msgf("%d. %s %d %s", i, a.FullVersion, a.PID, a.DataDir)
 54 | 		}
 55 | 
 56 | 		// step 2. dump memory
 57 | 		account := accounts[0]
 58 | 		file := fmt.Sprintf("wechat_%s_%d_%s.bin", account.FullVersion, account.PID, session)
 59 | 		path := filepath.Join(dir, file)
 60 | 		log.Info().Msgf("dumping memory to %s", path)
 61 | 
 62 | 		g := glance.NewGlance(account.PID)
 63 | 		b, err := g.Read()
 64 | 		if err != nil {
 65 | 			log.Fatal().Err(err).Msg("read memory failed")
 66 | 			return
 67 | 		}
 68 | 
 69 | 		if err = os.WriteFile(path, b, 0644); err != nil {
 70 | 			log.Fatal().Err(err).Msg("write memory failed")
 71 | 			return
 72 | 		}
 73 | 
 74 | 		log.Info().Msg("dump memory success")
 75 | 
 76 | 		// step 3. copy encrypted database file
 77 | 		dbFile := "db_storage/session/session.db"
 78 | 		if account.Version == 3 {
 79 | 			dbFile = "Session/session_new.db"
 80 | 		}
 81 | 		from := filepath.Join(account.DataDir, dbFile)
 82 | 		to := filepath.Join(dir, fmt.Sprintf("wechat_%s_%d_session.db", account.FullVersion, account.PID))
 83 | 
 84 | 		log.Info().Msgf("copying %s to %s", from, to)
 85 | 		b, err = os.ReadFile(from)
 86 | 		if err != nil {
 87 | 			log.Fatal().Err(err).Msg("read session.db failed")
 88 | 			return
 89 | 		}
 90 | 		if err = os.WriteFile(to, b, 0644); err != nil {
 91 | 			log.Fatal().Err(err).Msg("write session.db failed")
 92 | 			return
 93 | 		}
 94 | 		log.Info().Msg("copy session.db success")
 95 | 
 96 | 		// step 4. package
 97 | 		zipFile := fmt.Sprintf("wechat_%s_%d_%s.zip", account.FullVersion, account.PID, session)
 98 | 		zipPath := filepath.Join(dir, zipFile)
 99 | 		log.Info().Msgf("packaging to %s", zipPath)
100 | 
101 | 		zf, err := os.Create(zipPath)
102 | 		if err != nil {
103 | 			log.Fatal().Err(err).Msg("create zip file failed")
104 | 			return
105 | 		}
106 | 		defer zf.Close()
107 | 
108 | 		zw := zip.NewWriter(zf)
109 | 
110 | 		for _, file := range []string{file, to} {
111 | 			f, err := os.Open(file)
112 | 			if err != nil {
113 | 				log.Fatal().Err(err).Msg("open file failed")
114 | 				return
115 | 			}
116 | 			defer f.Close()
117 | 			info, err := f.Stat()
118 | 			if err != nil {
119 | 				log.Fatal().Err(err).Msg("get file info failed")
120 | 				return
121 | 			}
122 | 			header, err := zip.FileInfoHeader(info)
123 | 			if err != nil {
124 | 				log.Fatal().Err(err).Msg("create zip file info header failed")
125 | 				return
126 | 			}
127 | 			header.Name = filepath.Base(file)
128 | 			header.Method = zip.Deflate
129 | 			writer, err := zw.CreateHeader(header)
130 | 			if err != nil {
131 | 				log.Fatal().Err(err).Msg("create zip file header failed")
132 | 				return
133 | 			}
134 | 			if _, err = io.Copy(writer, f); err != nil {
135 | 				log.Fatal().Err(err).Msg("copy file to zip failed")
136 | 				return
137 | 			}
138 | 		}
139 | 		if err = zw.Close(); err != nil {
140 | 			log.Fatal().Err(err).Msg("close zip writer failed")
141 | 			return
142 | 		}
143 | 
144 | 		log.Info().Msgf("package success, please send %s to developer", zipPath)
145 | 	},
146 | }
147 | 


--------------------------------------------------------------------------------
/cmd/chatlog/cmd_key.go:
--------------------------------------------------------------------------------
 1 | package chatlog
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 
 6 | 	"github.com/sjzar/chatlog/internal/chatlog"
 7 | 
 8 | 	"github.com/rs/zerolog/log"
 9 | 	"github.com/spf13/cobra"
10 | )
11 | 
12 | func init() {
13 | 	rootCmd.AddCommand(keyCmd)
14 | 	keyCmd.Flags().IntVarP(&pid, "pid", "p", 0, "pid")
15 | }
16 | 
17 | var pid int
18 | var keyCmd = &cobra.Command{
19 | 	Use:   "key",
20 | 	Short: "key",
21 | 	Run: func(cmd *cobra.Command, args []string) {
22 | 		m, err := chatlog.New("")
23 | 		if err != nil {
24 | 			log.Err(err).Msg("failed to create chatlog instance")
25 | 			return
26 | 		}
27 | 		ret, err := m.CommandKey(pid)
28 | 		if err != nil {
29 | 			log.Err(err).Msg("failed to get key")
30 | 			return
31 | 		}
32 | 		fmt.Println(ret)
33 | 	},
34 | }
35 | 


--------------------------------------------------------------------------------
/cmd/chatlog/cmd_server.go:
--------------------------------------------------------------------------------
 1 | package chatlog
 2 | 
 3 | import (
 4 | 	"runtime"
 5 | 
 6 | 	"github.com/sjzar/chatlog/internal/chatlog"
 7 | 
 8 | 	"github.com/rs/zerolog/log"
 9 | 	"github.com/spf13/cobra"
10 | )
11 | 
12 | func init() {
13 | 	rootCmd.AddCommand(serverCmd)
14 | 	serverCmd.Flags().StringVarP(&serverAddr, "addr", "a", "127.0.0.1:5030", "server address")
15 | 	serverCmd.Flags().StringVarP(&serverDataDir, "data-dir", "d", "", "data dir")
16 | 	serverCmd.Flags().StringVarP(&serverWorkDir, "work-dir", "w", "", "work dir")
17 | 	serverCmd.Flags().StringVarP(&serverPlatform, "platform", "p", runtime.GOOS, "platform")
18 | 	serverCmd.Flags().IntVarP(&serverVer, "version", "v", 3, "version")
19 | }
20 | 
21 | var (
22 | 	serverAddr     string
23 | 	serverDataDir  string
24 | 	serverWorkDir  string
25 | 	serverPlatform string
26 | 	serverVer      int
27 | )
28 | 
29 | var serverCmd = &cobra.Command{
30 | 	Use:   "server",
31 | 	Short: "Start HTTP server",
32 | 	Run: func(cmd *cobra.Command, args []string) {
33 | 		m, err := chatlog.New("")
34 | 		if err != nil {
35 | 			log.Err(err).Msg("failed to create chatlog instance")
36 | 			return
37 | 		}
38 | 		if err := m.CommandHTTPServer(serverAddr, serverDataDir, serverWorkDir, serverPlatform, serverVer); err != nil {
39 | 			log.Err(err).Msg("failed to start server")
40 | 			return
41 | 		}
42 | 	},
43 | }
44 | 


--------------------------------------------------------------------------------
/cmd/chatlog/cmd_version.go:
--------------------------------------------------------------------------------
 1 | package chatlog
 2 | 
 3 | import (
 4 | 	"fmt"
 5 | 
 6 | 	"github.com/sjzar/chatlog/pkg/version"
 7 | 
 8 | 	"github.com/spf13/cobra"
 9 | )
10 | 
11 | func init() {
12 | 	rootCmd.AddCommand(versionCmd)
13 | 	versionCmd.Flags().BoolVarP(&versionM, "module", "m", false, "module version information")
14 | }
15 | 
16 | var versionM bool
17 | var versionCmd = &cobra.Command{
18 | 	Use:   "version [-m]",
19 | 	Short: "Show the version of chatlog",
20 | 	Run: func(cmd *cobra.Command, args []string) {
21 | 		if versionM {
22 | 			fmt.Println(version.GetMore(true))
23 | 		} else {
24 | 			fmt.Printf("chatlog %s\n", version.GetMore(false))
25 | 		}
26 | 	},
27 | }
28 | 


--------------------------------------------------------------------------------
/cmd/chatlog/log.go:
--------------------------------------------------------------------------------
 1 | package chatlog
 2 | 
 3 | import (
 4 | 	"io"
 5 | 	"os"
 6 | 	"path/filepath"
 7 | 	"time"
 8 | 
 9 | 	"github.com/sjzar/chatlog/pkg/util"
10 | 
11 | 	"github.com/rs/zerolog"
12 | 	"github.com/rs/zerolog/log"
13 | 	"github.com/sirupsen/logrus"
14 | 	"github.com/spf13/cobra"
15 | )
16 | 
17 | var Debug bool
18 | 
19 | func initLog(cmd *cobra.Command, args []string) {
20 | 	zerolog.SetGlobalLevel(zerolog.InfoLevel)
21 | 
22 | 	if Debug {
23 | 		zerolog.SetGlobalLevel(zerolog.DebugLevel)
24 | 	}
25 | 
26 | 	log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
27 | }
28 | 
29 | func initTuiLog(cmd *cobra.Command, args []string) {
30 | 	logOutput := io.Discard
31 | 
32 | 	debug, _ := cmd.Flags().GetBool("debug")
33 | 	if debug {
34 | 		logpath := util.DefaultWorkDir("")
35 | 		util.PrepareDir(logpath)
36 | 		logFD, err := os.OpenFile(filepath.Join(logpath, "chatlog.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm)
37 | 		if err != nil {
38 | 			panic(err)
39 | 		}
40 | 		logOutput = logFD
41 | 	}
42 | 
43 | 	log.Logger = log.Output(zerolog.ConsoleWriter{Out: logOutput, NoColor: true, TimeFormat: time.RFC3339})
44 | 	logrus.SetOutput(logOutput)
45 | }
46 | 


--------------------------------------------------------------------------------
/cmd/chatlog/root.go:
--------------------------------------------------------------------------------
 1 | package chatlog
 2 | 
 3 | import (
 4 | 	"github.com/sjzar/chatlog/internal/chatlog"
 5 | 
 6 | 	"github.com/rs/zerolog/log"
 7 | 	"github.com/spf13/cobra"
 8 | )
 9 | 
10 | func init() {
11 | 	// windows only
12 | 	cobra.MousetrapHelpText = ""
13 | 
14 | 	rootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "debug")
15 | 	rootCmd.PersistentPreRun = initLog
16 | }
17 | 
18 | func Execute() {
19 | 	if err := rootCmd.Execute(); err != nil {
20 | 		log.Err(err).Msg("command execution failed")
21 | 	}
22 | }
23 | 
24 | var rootCmd = &cobra.Command{
25 | 	Use:     "chatlog",
26 | 	Short:   "chatlog",
27 | 	Long:    `chatlog`,
28 | 	Example: `chatlog`,
29 | 	Args:    cobra.MinimumNArgs(0),
30 | 	CompletionOptions: cobra.CompletionOptions{
31 | 		HiddenDefaultCmd: true,
32 | 	},
33 | 	PreRun: initTuiLog,
34 | 	Run:    Root,
35 | }
36 | 
37 | func Root(cmd *cobra.Command, args []string) {
38 | 
39 | 	m, err := chatlog.New("")
40 | 	if err != nil {
41 | 		log.Err(err).Msg("failed to create chatlog instance")
42 | 		return
43 | 	}
44 | 
45 | 	if err := m.Run(); err != nil {
46 | 		log.Err(err).Msg("failed to run chatlog instance")
47 | 	}
48 | }
49 | 


--------------------------------------------------------------------------------
/docs/mcp.md:
--------------------------------------------------------------------------------
  1 | # MCP 集成指南
  2 | 
  3 | ## 目录
  4 | - [MCP 集成指南](#mcp-集成指南)
  5 |   - [目录](#目录)
  6 |   - [前期准备](#前期准备)
  7 |     - [mcp-proxy](#mcp-proxy)
  8 |   - [ChatWise](#chatwise)
  9 |   - [Cherry Studio](#cherry-studio)
 10 |   - [Claude Desktop](#claude-desktop)
 11 |   - [Monica Code](#monica-code)
 12 | 
 13 | 
 14 | ## 前期准备
 15 | 
 16 | 运行 `chatlog`,完成数据解密并开启 HTTP 服务
 17 | 
 18 | ### mcp-proxy
 19 | 如果遇到不支持 `SSE` 的客户端,可以尝试使用 `mcp-proxy` 将 `stdio` 的请求转换为 `SSE`。  
 20 | 
 21 | 项目地址:https://github.com/sparfenyuk/mcp-proxy
 22 | 
 23 | 安装方式:
 24 | ```shell
 25 | # 使用 uv 工具安装,也可参考项目文档的其他安装方式
 26 | uv tool install mcp-proxy
 27 | 
 28 | # 查询 mcp-proxy 的路径,后续可直接使用该路径
 29 | which mcp-proxy
 30 | /Users/sarv/.local/bin/mcp-proxy
 31 | ```
 32 | 
 33 | ## ChatWise
 34 | 
 35 | - 官网:https://chatwise.app/
 36 | - 使用方式:MCP SSE
 37 | - 注意事项:使用 ChatWise 的 MCP 功能需要 Pro 权限
 38 | 
 39 | 1. 在 `设置 - 工具` 下新建 `SSE 请求` 工具
 40 | 
 41 | ![chatwise-1](https://github.com/user-attachments/assets/87e40f39-9fbc-4ff1-954a-d95548cde4c2)
 42 | 
 43 | 1. 在 URL 中填写 `http://127.0.0.1:5030/sse`,并勾选 `自动执行工具`,点击 `查看工具` 即可检查连接 `chatlog` 是否正常
 44 | 
 45 | ![chatwise-2](https://github.com/user-attachments/assets/8f98ef18-8e6c-40e6-ae78-8cd13e411c36)
 46 | 
 47 | 3. 返回主页,选择支持 MCP 调用的模型,打开 `chatlog` 工具选项
 48 | 
 49 | ![chatwise-3](https://github.com/user-attachments/assets/ea2aa178-5439-492b-a92f-4f4fc08828e7)
 50 | 
 51 | 4. 测试功能是否正常
 52 | 
 53 | ![chatwise-4](https://github.com/user-attachments/assets/8f82cb53-8372-40ee-a299-c02d3399403a)
 54 | 
 55 | ## Cherry Studio
 56 | 
 57 | - 官网:https://cherry-ai.com/
 58 | - 使用方式:MCP SSE
 59 | 
 60 | 1. 在 `设置 - MCP 服务器` 下点击 `添加服务器`,输入名称为 `chatlog`,选择类型为 `服务器发送事件(sse)`,填写 URL 为 `http://127.0.0.1:5030/sse`,点击 `保存`。(注意:点击保存前不要先点击左侧的开启按钮)
 61 | 
 62 | ![cherry-1](https://github.com/user-attachments/assets/93fc8b0a-9d95-499e-ab6c-e22b0c96fd6a)
 63 | 
 64 | 2. 选择支持 MCP 调用的模型,打开 `chatlog` 工具选项
 65 | 
 66 | ![cherry-2](https://github.com/user-attachments/assets/4e5bf752-2eab-4e7c-b73b-1b759d4a5f29)
 67 | 
 68 | 3. 测试功能是否正常
 69 | 
 70 | ![cherry-3](https://github.com/user-attachments/assets/c58a019f-fd5f-4fa3-830a-e81a60f2aa6f)
 71 | 
 72 | ## Claude Desktop
 73 | 
 74 | - 官网:https://claude.ai/download
 75 | - 使用方式:mcp-proxy
 76 | - 参考资料:https://modelcontextprotocol.io/quickstart/user#2-add-the-filesystem-mcp-server
 77 | 
 78 | 1. 请先参考 [mcp-proxy](#mcp-proxy) 安装 `mcp-proxy`
 79 | 
 80 | 2. 进入 Claude Desktop `Settings - Developer`,点击 `Edit Config` 按钮,这样会创建一个 `claude_desktop_config.json` 配置文件,并引导你编辑该文件
 81 | 
 82 | 3. 编辑 `claude_desktop_config.json` 文件,配置名称为 `chatlog`,command 为 `mcp-proxy` 的路径,args 为 `http://127.0.0.1:5030/sse`,如下所示:
 83 | 
 84 | ```json
 85 | {
 86 |   "mcpServers": {
 87 |     "chatlog": {
 88 |       "command": "/Users/sarv/.local/bin/mcp-proxy",
 89 |       "args": [
 90 |         "http://localhost:5030/sse"
 91 |       ]
 92 |     }
 93 |   },
 94 |   "globalShortcut": ""
 95 | }
 96 | ```
 97 | 
 98 | 4. 保存 `claude_desktop_config.json` 文件,重启 Claude Desktop,可以看到 `chatlog` 已经添加成功
 99 | 
100 | ![claude-1](https://github.com/user-attachments/assets/f4e872cc-e6c1-4e24-97da-266466949cdf)
101 | 
102 | 5. 测试功能是否正常
103 | 
104 | ![claude-2](https://github.com/user-attachments/assets/832bb4d2-3639-4cbc-8b17-f4b812ea3637)
105 | 
106 | 
107 | ## Monica Code
108 | 
109 | - 官网:https://monica.im/en/code
110 | - 使用方式:mcp-proxy
111 | - 参考资料:https://github.com/Monica-IM/Monica-Code/blob/main/Reference/config.md#modelcontextprotocolserver
112 | 
113 | 1. 请先参考 [mcp-proxy](#mcp-proxy) 安装 `mcp-proxy`
114 | 
115 | 2. 在 vscode 插件文件夹(`~/.vscode/extensions`)下找到 Monica Code 的目录,编辑 `config_schema.json` 文件。将 `experimental - modelContextProtocolServer` 中 `transport` 设置为如下内容:
116 | 
117 | ```json
118 | {
119 |   "experimental": {
120 |     "type": "object",
121 |     "title": "Experimental",
122 |     "description": "Experimental properties are subject to change.",
123 |     "properties": {
124 |       "modelContextProtocolServer": {
125 |         "type": "object",
126 |         "properties": {
127 |           "transport": {
128 |             "type": "stdio",
129 |             "command": "/Users/sarv/.local/bin/mcp-proxy",
130 |             "args": [
131 |               "http://localhost:5030/sse"
132 |             ]
133 |           }
134 |         },
135 |         "required": [
136 |           "transport"
137 |         ]
138 |       }
139 |     }
140 |   }
141 | }
142 | ```
143 | 
144 | 3. 重启 vscode,可以看到 `chatlog` 已经添加成功
145 | 
146 | ![monica-1](https://github.com/user-attachments/assets/8d0a96f2-ed05-48aa-a99a-06648ae1c500)
147 | 
148 | 4. 测试功能是否正常
149 | 
150 | ![monica-2](https://github.com/user-attachments/assets/054e0a30-428a-48a6-9f31-d2596fb8f743)
151 | 
152 | 


--------------------------------------------------------------------------------
/docs/prompt.md:
--------------------------------------------------------------------------------
 1 | # Prompt 指南
 2 | 
 3 | ## 概述
 4 | 优秀的 `prompt` 可以极大的提高 `chatlog` 使用体验,收集了部分群友分享的 `prompt`,供大家参考。  
 5 | 在处理聊天记录时,尽量选择上下文长度足够的 LLM,例如 `Gemini 2.5 Pro`、`Claude 3.5 Sonnet` 等。  
 6 | 欢迎大家在 [Discussions](https://github.com/sjzar/chatlog/discussions/47) 中分享自己的使用方式,共同进步。
 7 | 
 8 | 
 9 | ## 群聊总结
10 | 作者:@eyaeya
11 | 
12 | ```md
13 | 你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
14 | 
15 | 请帮我将 "<talker>" 在 <Time> 的群聊内容总结成一个群聊报告,包含不多于5个的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
16 | - 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
17 | - 参与者(不超过5个人,将重复的人名去重)
18 | - 时间段(从几点到几点)
19 | - 过程(50到200字左右)
20 | - 评价(50字以下)
21 | - 分割线: ------------
22 | 
23 | 另外有以下要求:
24 | 1. 每个话题结束使用 ------------ 分割
25 | 2. 使用中文冒号
26 | 3. 无需大标题
27 | 4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类
28 | 
29 | 最后总结下最活跃的前五个发言者。 
30 | ```
31 | 
32 | ## 微信聊天记录可视化
33 | 作者:@数字声明卡兹克  
34 | 原文地址:https://mp.weixin.qq.com/s/Z66YRjY1EnC_hMgXE9_nnw  
35 | Prompt:[微信聊天记录可视化prompt.txt](https://github.com/user-attachments/files/19773263/prompt.txt)
36 | 
37 | 这份 prompt 可以使用聊天记录生成 HTML 网页,再使用 [YOURWARE](https://www.yourware.so/) 部署为可分享的静态网页。
38 | 
39 | ### 技术讨论分析
40 | 作者:@eyaeya
41 | 
42 | ```md
43 | 你作为一个专业的技术讨论分析者,请对以下聊天记录进行分析和结构化总结:
44 | 
45 | 1. 基础信息提取:
46 | - 将每个主题分成独立的问答对
47 | - 保持原始对话的时间顺序
48 | 
49 | 1. 问题分析要点:
50 | - 提取问题的具体场景和背景
51 | - 识别问题的核心技术难点
52 | - 突出问题的实际影响
53 | 
54 | 1. 解决方案总结:
55 | - 列出具体的解决步骤
56 | - 提取关键工具和资源
57 | - 包含实践经验和注意事项
58 | - 保留重要的链接和参考资料
59 | 
60 | 1. 输出格式:
61 | - 不要输出"日期:YYYY-MM-DD"这一行,直接从问题1开始 
62 | - 问题1:<简明扼要的问题描述>
63 | - 回答1:<完整的解决方案>
64 | - 补充:<额外的讨论要点或注意事项>
65 | 
66 | 1. 额外要求(严格执行):
67 | - 如果有多个相关问题,保持逻辑顺序
68 | - 标记重要的警告和建议、突出经验性的分享内容、保留有价值的专业术语解释、移除"我来分析"等过渡语确保链接的完整性
69 | - 直接以日期开始,不要添加任何开场白
70 | ```


--------------------------------------------------------------------------------
/go.mod:
--------------------------------------------------------------------------------
 1 | module github.com/sjzar/chatlog
 2 | 
 3 | go 1.24.0
 4 | 
 5 | require (
 6 | 	github.com/gdamore/tcell/v2 v2.8.1
 7 | 	github.com/gin-gonic/gin v1.10.0
 8 | 	github.com/google/uuid v1.6.0
 9 | 	github.com/klauspost/compress v1.18.0
10 | 	github.com/mattn/go-sqlite3 v1.14.27
11 | 	github.com/pierrec/lz4/v4 v4.1.22
12 | 	github.com/rivo/tview v0.0.0-20250330220935-949945f8d922
13 | 	github.com/rs/zerolog v1.34.0
14 | 	github.com/shirou/gopsutil/v4 v4.25.3
15 | 	github.com/sirupsen/logrus v1.9.3
16 | 	github.com/sjzar/go-lame v0.0.8
17 | 	github.com/sjzar/go-silk v0.0.1
18 | 	github.com/spf13/cobra v1.9.1
19 | 	github.com/spf13/viper v1.20.1
20 | 	golang.org/x/crypto v0.37.0
21 | 	golang.org/x/sys v0.32.0
22 | 	google.golang.org/protobuf v1.36.6
23 | 	howett.net/plist v1.0.1
24 | )
25 | 
26 | require (
27 | 	github.com/bytedance/sonic v1.13.2 // indirect
28 | 	github.com/bytedance/sonic/loader v0.2.4 // indirect
29 | 	github.com/cloudwego/base64x v0.1.5 // indirect
30 | 	github.com/ebitengine/purego v0.8.2 // indirect
31 | 	github.com/fsnotify/fsnotify v1.9.0 // indirect
32 | 	github.com/gabriel-vasile/mimetype v1.4.8 // indirect
33 | 	github.com/gdamore/encoding v1.0.1 // indirect
34 | 	github.com/gin-contrib/sse v1.1.0 // indirect
35 | 	github.com/go-ole/go-ole v1.3.0 // indirect
36 | 	github.com/go-playground/locales v0.14.1 // indirect
37 | 	github.com/go-playground/universal-translator v0.18.1 // indirect
38 | 	github.com/go-playground/validator/v10 v10.26.0 // indirect
39 | 	github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
40 | 	github.com/goccy/go-json v0.10.5 // indirect
41 | 	github.com/inconshreveable/mousetrap v1.1.0 // indirect
42 | 	github.com/json-iterator/go v1.1.12 // indirect
43 | 	github.com/klauspost/cpuid/v2 v2.2.10 // indirect
44 | 	github.com/leodido/go-urn v1.4.0 // indirect
45 | 	github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
46 | 	github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
47 | 	github.com/mattn/go-colorable v0.1.14 // indirect
48 | 	github.com/mattn/go-isatty v0.0.20 // indirect
49 | 	github.com/mattn/go-runewidth v0.0.16 // indirect
50 | 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
51 | 	github.com/modern-go/reflect2 v1.0.2 // indirect
52 | 	github.com/pelletier/go-toml/v2 v2.2.4 // indirect
53 | 	github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
54 | 	github.com/rivo/uniseg v0.4.7 // indirect
55 | 	github.com/sagikazarmark/locafero v0.9.0 // indirect
56 | 	github.com/sourcegraph/conc v0.3.0 // indirect
57 | 	github.com/spf13/afero v1.14.0 // indirect
58 | 	github.com/spf13/cast v1.7.1 // indirect
59 | 	github.com/spf13/pflag v1.0.6 // indirect
60 | 	github.com/subosito/gotenv v1.6.0 // indirect
61 | 	github.com/tklauser/go-sysconf v0.3.15 // indirect
62 | 	github.com/tklauser/numcpus v0.10.0 // indirect
63 | 	github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
64 | 	github.com/ugorji/go/codec v1.2.12 // indirect
65 | 	github.com/yusufpapurcu/wmi v1.2.4 // indirect
66 | 	go.uber.org/multierr v1.11.0 // indirect
67 | 	golang.org/x/arch v0.16.0 // indirect
68 | 	golang.org/x/net v0.39.0 // indirect
69 | 	golang.org/x/term v0.31.0 // indirect
70 | 	golang.org/x/text v0.24.0 // indirect
71 | 	gopkg.in/yaml.v3 v3.0.1 // indirect
72 | )
73 | 


--------------------------------------------------------------------------------
/internal/chatlog/conf/config.go:
--------------------------------------------------------------------------------
 1 | package conf
 2 | 
 3 | import "github.com/sjzar/chatlog/pkg/config"
 4 | 
 5 | type Config struct {
 6 | 	ConfigDir   string          `mapstructure:"-"`
 7 | 	LastAccount string          `mapstructure:"last_account" json:"last_account"`
 8 | 	History     []ProcessConfig `mapstructure:"history" json:"history"`
 9 | }
10 | 
11 | type ProcessConfig struct {
12 | 	Type        string `mapstructure:"type" json:"type"`
13 | 	Account     string `mapstructure:"account" json:"account"`
14 | 	Platform    string `mapstructure:"platform" json:"platform"`
15 | 	Version     int    `mapstructure:"version" json:"version"`
16 | 	FullVersion string `mapstructure:"full_version" json:"full_version"`
17 | 	DataDir     string `mapstructure:"data_dir" json:"data_dir"`
18 | 	DataKey     string `mapstructure:"data_key" json:"data_key"`
19 | 	WorkDir     string `mapstructure:"work_dir" json:"work_dir"`
20 | 	HTTPEnabled bool   `mapstructure:"http_enabled" json:"http_enabled"`
21 | 	HTTPAddr    string `mapstructure:"http_addr" json:"http_addr"`
22 | 	LastTime    int64  `mapstructure:"last_time" json:"last_time"`
23 | 	Files       []File `mapstructure:"files" json:"files"`
24 | }
25 | 
26 | type File struct {
27 | 	Path         string `mapstructure:"path" json:"path"`
28 | 	ModifiedTime int64  `mapstructure:"modified_time" json:"modified_time"`
29 | 	Size         int64  `mapstructure:"size" json:"size"`
30 | }
31 | 
32 | func (c *Config) ParseHistory() map[string]ProcessConfig {
33 | 	m := make(map[string]ProcessConfig)
34 | 	for _, v := range c.History {
35 | 		m[v.Account] = v
36 | 	}
37 | 	return m
38 | }
39 | 
40 | func (c *Config) UpdateHistory(account string, conf ProcessConfig) error {
41 | 	if c.History == nil {
42 | 		c.History = make([]ProcessConfig, 0)
43 | 	}
44 | 	if len(c.History) == 0 {
45 | 		c.History = append(c.History, conf)
46 | 	} else {
47 | 		isFind := false
48 | 		for i, v := range c.History {
49 | 			if v.Account == account {
50 | 				isFind = true
51 | 				c.History[i] = conf
52 | 				break
53 | 			}
54 | 		}
55 | 		if !isFind {
56 | 			c.History = append(c.History, conf)
57 | 		}
58 | 	}
59 | 	config.SetConfig("last_account", account)
60 | 	return config.SetConfig("history", c.History)
61 | }
62 | 


--------------------------------------------------------------------------------
/internal/chatlog/conf/service.go:
--------------------------------------------------------------------------------
 1 | package conf
 2 | 
 3 | import (
 4 | 	"log"
 5 | 	"os"
 6 | 	"sync"
 7 | 
 8 | 	"github.com/sjzar/chatlog/pkg/config"
 9 | )
10 | 
11 | const (
12 | 	ConfigName   = "chatlog"
13 | 	ConfigType   = "json"
14 | 	EnvConfigDir = "CHATLOG_DIR"
15 | )
16 | 
17 | // Service 配置服务
18 | type Service struct {
19 | 	configPath string
20 | 	config     *Config
21 | 	mu         sync.RWMutex
22 | }
23 | 
24 | // NewService 创建配置服务
25 | func NewService(configPath string) (*Service, error) {
26 | 
27 | 	service := &Service{
28 | 		configPath: configPath,
29 | 	}
30 | 
31 | 	if err := service.Load(); err != nil {
32 | 		return nil, err
33 | 	}
34 | 
35 | 	return service, nil
36 | }
37 | 
38 | // Load 加载配置
39 | func (s *Service) Load() error {
40 | 	s.mu.Lock()
41 | 	defer s.mu.Unlock()
42 | 
43 | 	configPath := s.configPath
44 | 	if configPath == "" {
45 | 		configPath = os.Getenv(EnvConfigDir)
46 | 	}
47 | 	if err := config.Init(ConfigName, ConfigType, configPath); err != nil {
48 | 		log.Fatal(err)
49 | 	}
50 | 
51 | 	conf := &Config{}
52 | 	if err := config.Load(conf); err != nil {
53 | 		log.Fatal(err)
54 | 	}
55 | 	conf.ConfigDir = config.ConfigPath
56 | 	s.config = conf
57 | 	return nil
58 | }
59 | 
60 | // GetConfig 获取配置副本
61 | func (s *Service) GetConfig() *Config {
62 | 	s.mu.RLock()
63 | 	defer s.mu.RUnlock()
64 | 
65 | 	// 返回配置副本
66 | 	configCopy := *s.config
67 | 	return &configCopy
68 | }
69 | 


--------------------------------------------------------------------------------
/internal/chatlog/ctx/context.go:
--------------------------------------------------------------------------------
  1 | package ctx
  2 | 
  3 | import (
  4 | 	"sync"
  5 | 	"time"
  6 | 
  7 | 	"github.com/sjzar/chatlog/internal/chatlog/conf"
  8 | 	"github.com/sjzar/chatlog/internal/wechat"
  9 | 	"github.com/sjzar/chatlog/pkg/util"
 10 | )
 11 | 
 12 | // Context is a context for a chatlog.
 13 | // It is used to store information about the chatlog.
 14 | type Context struct {
 15 | 	conf *conf.Service
 16 | 	mu   sync.RWMutex
 17 | 
 18 | 	History map[string]conf.ProcessConfig
 19 | 
 20 | 	// 微信账号相关状态
 21 | 	Account     string
 22 | 	Platform    string
 23 | 	Version     int
 24 | 	FullVersion string
 25 | 	DataDir     string
 26 | 	DataKey     string
 27 | 	DataUsage   string
 28 | 
 29 | 	// 工作目录相关状态
 30 | 	WorkDir   string
 31 | 	WorkUsage string
 32 | 
 33 | 	// HTTP服务相关状态
 34 | 	HTTPEnabled bool
 35 | 	HTTPAddr    string
 36 | 
 37 | 	// 自动解密
 38 | 	AutoDecrypt bool
 39 | 	LastSession time.Time
 40 | 
 41 | 	// 当前选中的微信实例
 42 | 	Current *wechat.Account
 43 | 	PID     int
 44 | 	ExePath string
 45 | 	Status  string
 46 | 
 47 | 	// 所有可用的微信实例
 48 | 	WeChatInstances []*wechat.Account
 49 | }
 50 | 
 51 | func New(conf *conf.Service) *Context {
 52 | 	ctx := &Context{
 53 | 		conf: conf,
 54 | 	}
 55 | 
 56 | 	ctx.loadConfig()
 57 | 
 58 | 	return ctx
 59 | }
 60 | 
 61 | func (c *Context) loadConfig() {
 62 | 	conf := c.conf.GetConfig()
 63 | 	c.History = conf.ParseHistory()
 64 | 	c.SwitchHistory(conf.LastAccount)
 65 | 	c.Refresh()
 66 | }
 67 | 
 68 | func (c *Context) SwitchHistory(account string) {
 69 | 	c.mu.Lock()
 70 | 	defer c.mu.Unlock()
 71 | 	c.Current = nil
 72 | 	c.PID = 0
 73 | 	c.ExePath = ""
 74 | 	c.Status = ""
 75 | 	history, ok := c.History[account]
 76 | 	if ok {
 77 | 		c.Account = history.Account
 78 | 		c.Platform = history.Platform
 79 | 		c.Version = history.Version
 80 | 		c.FullVersion = history.FullVersion
 81 | 		c.DataKey = history.DataKey
 82 | 		c.DataDir = history.DataDir
 83 | 		c.WorkDir = history.WorkDir
 84 | 		c.HTTPEnabled = history.HTTPEnabled
 85 | 		c.HTTPAddr = history.HTTPAddr
 86 | 	} else {
 87 | 		c.Account = ""
 88 | 		c.Platform = ""
 89 | 		c.Version = 0
 90 | 		c.FullVersion = ""
 91 | 		c.DataKey = ""
 92 | 		c.DataDir = ""
 93 | 		c.WorkDir = ""
 94 | 		c.HTTPEnabled = false
 95 | 		c.HTTPAddr = ""
 96 | 	}
 97 | }
 98 | 
 99 | func (c *Context) SwitchCurrent(info *wechat.Account) {
100 | 	c.SwitchHistory(info.Name)
101 | 	c.mu.Lock()
102 | 	defer c.mu.Unlock()
103 | 	c.Current = info
104 | 	c.Refresh()
105 | 
106 | }
107 | func (c *Context) Refresh() {
108 | 	if c.Current != nil {
109 | 		c.Account = c.Current.Name
110 | 		c.Platform = c.Current.Platform
111 | 		c.Version = c.Current.Version
112 | 		c.FullVersion = c.Current.FullVersion
113 | 		c.PID = int(c.Current.PID)
114 | 		c.ExePath = c.Current.ExePath
115 | 		c.Status = c.Current.Status
116 | 		if c.Current.Key != "" && c.Current.Key != c.DataKey {
117 | 			c.DataKey = c.Current.Key
118 | 		}
119 | 		if c.Current.DataDir != "" && c.Current.DataDir != c.DataDir {
120 | 			c.DataDir = c.Current.DataDir
121 | 		}
122 | 	}
123 | 	if c.DataUsage == "" && c.DataDir != "" {
124 | 		go func() {
125 | 			c.DataUsage = util.GetDirSize(c.DataDir)
126 | 		}()
127 | 	}
128 | 	if c.WorkUsage == "" && c.WorkDir != "" {
129 | 		go func() {
130 | 			c.WorkUsage = util.GetDirSize(c.WorkDir)
131 | 		}()
132 | 	}
133 | }
134 | 
135 | func (c *Context) SetHTTPEnabled(enabled bool) {
136 | 	c.mu.Lock()
137 | 	defer c.mu.Unlock()
138 | 	c.HTTPEnabled = enabled
139 | 	c.UpdateConfig()
140 | }
141 | 
142 | func (c *Context) SetHTTPAddr(addr string) {
143 | 	c.mu.Lock()
144 | 	defer c.mu.Unlock()
145 | 	c.HTTPAddr = addr
146 | 	c.UpdateConfig()
147 | }
148 | 
149 | func (c *Context) SetWorkDir(dir string) {
150 | 	c.mu.Lock()
151 | 	defer c.mu.Unlock()
152 | 	c.WorkDir = dir
153 | 	c.UpdateConfig()
154 | 	c.Refresh()
155 | }
156 | 
157 | func (c *Context) SetDataDir(dir string) {
158 | 	c.mu.Lock()
159 | 	defer c.mu.Unlock()
160 | 	c.DataDir = dir
161 | 	c.UpdateConfig()
162 | 	c.Refresh()
163 | }
164 | 
165 | func (c *Context) SetAutoDecrypt(enabled bool) {
166 | 	c.mu.Lock()
167 | 	defer c.mu.Unlock()
168 | 	c.AutoDecrypt = enabled
169 | 	c.UpdateConfig()
170 | }
171 | 
172 | // 更新配置
173 | func (c *Context) UpdateConfig() {
174 | 	pconf := conf.ProcessConfig{
175 | 		Type:        "wechat",
176 | 		Account:     c.Account,
177 | 		Platform:    c.Platform,
178 | 		Version:     c.Version,
179 | 		FullVersion: c.FullVersion,
180 | 		DataDir:     c.DataDir,
181 | 		DataKey:     c.DataKey,
182 | 		WorkDir:     c.WorkDir,
183 | 		HTTPEnabled: c.HTTPEnabled,
184 | 		HTTPAddr:    c.HTTPAddr,
185 | 	}
186 | 	conf := c.conf.GetConfig()
187 | 	conf.UpdateHistory(c.Account, pconf)
188 | }
189 | 


--------------------------------------------------------------------------------
/internal/chatlog/database/service.go:
--------------------------------------------------------------------------------
 1 | package database
 2 | 
 3 | import (
 4 | 	"time"
 5 | 
 6 | 	"github.com/sjzar/chatlog/internal/chatlog/ctx"
 7 | 	"github.com/sjzar/chatlog/internal/model"
 8 | 	"github.com/sjzar/chatlog/internal/wechatdb"
 9 | )
10 | 
11 | type Service struct {
12 | 	ctx *ctx.Context
13 | 	db  *wechatdb.DB
14 | }
15 | 
16 | func NewService(ctx *ctx.Context) *Service {
17 | 	return &Service{
18 | 		ctx: ctx,
19 | 	}
20 | }
21 | 
22 | func (s *Service) Start() error {
23 | 	db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.Platform, s.ctx.Version)
24 | 	if err != nil {
25 | 		return err
26 | 	}
27 | 	s.db = db
28 | 	return nil
29 | }
30 | 
31 | func (s *Service) Stop() error {
32 | 	if s.db != nil {
33 | 		s.db.Close()
34 | 	}
35 | 	s.db = nil
36 | 	return nil
37 | }
38 | 
39 | func (s *Service) GetDB() *wechatdb.DB {
40 | 	return s.db
41 | }
42 | 
43 | func (s *Service) GetMessages(start, end time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
44 | 	return s.db.GetMessages(start, end, talker, sender, keyword, limit, offset)
45 | }
46 | 
47 | func (s *Service) GetContacts(key string, limit, offset int) (*wechatdb.GetContactsResp, error) {
48 | 	return s.db.GetContacts(key, limit, offset)
49 | }
50 | 
51 | func (s *Service) GetChatRooms(key string, limit, offset int) (*wechatdb.GetChatRoomsResp, error) {
52 | 	return s.db.GetChatRooms(key, limit, offset)
53 | }
54 | 
55 | // GetSession retrieves session information
56 | func (s *Service) GetSessions(key string, limit, offset int) (*wechatdb.GetSessionsResp, error) {
57 | 	return s.db.GetSessions(key, limit, offset)
58 | }
59 | 
60 | func (s *Service) GetMedia(_type string, key string) (*model.Media, error) {
61 | 	return s.db.GetMedia(_type, key)
62 | }
63 | 
64 | // Close closes the database connection
65 | func (s *Service) Close() {
66 | 	// Add cleanup code if needed
67 | 	s.db.Close()
68 | }
69 | 


--------------------------------------------------------------------------------
/internal/chatlog/http/service.go:
--------------------------------------------------------------------------------
  1 | package http
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"net/http"
  6 | 	"time"
  7 | 
  8 | 	"github.com/sjzar/chatlog/internal/chatlog/ctx"
  9 | 	"github.com/sjzar/chatlog/internal/chatlog/database"
 10 | 	"github.com/sjzar/chatlog/internal/chatlog/mcp"
 11 | 	"github.com/sjzar/chatlog/internal/errors"
 12 | 
 13 | 	"github.com/gin-gonic/gin"
 14 | 	"github.com/rs/zerolog/log"
 15 | )
 16 | 
 17 | const (
 18 | 	DefalutHTTPAddr = "127.0.0.1:5030"
 19 | )
 20 | 
 21 | type Service struct {
 22 | 	ctx *ctx.Context
 23 | 	db  *database.Service
 24 | 	mcp *mcp.Service
 25 | 
 26 | 	router *gin.Engine
 27 | 	server *http.Server
 28 | }
 29 | 
 30 | func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Service {
 31 | 	gin.SetMode(gin.ReleaseMode)
 32 | 	router := gin.New()
 33 | 
 34 | 	// Handle error from SetTrustedProxies
 35 | 	if err := router.SetTrustedProxies(nil); err != nil {
 36 | 		log.Err(err).Msg("Failed to set trusted proxies")
 37 | 	}
 38 | 
 39 | 	// Middleware
 40 | 	router.Use(
 41 | 		errors.RecoveryMiddleware(),
 42 | 		errors.ErrorHandlerMiddleware(),
 43 | 		gin.LoggerWithWriter(log.Logger),
 44 | 	)
 45 | 
 46 | 	s := &Service{
 47 | 		ctx:    ctx,
 48 | 		db:     db,
 49 | 		mcp:    mcp,
 50 | 		router: router,
 51 | 	}
 52 | 
 53 | 	s.initRouter()
 54 | 	return s
 55 | }
 56 | 
 57 | func (s *Service) Start() error {
 58 | 
 59 | 	if s.ctx.HTTPAddr == "" {
 60 | 		s.ctx.HTTPAddr = DefalutHTTPAddr
 61 | 	}
 62 | 
 63 | 	s.server = &http.Server{
 64 | 		Addr:    s.ctx.HTTPAddr,
 65 | 		Handler: s.router,
 66 | 	}
 67 | 
 68 | 	go func() {
 69 | 		// Handle error from Run
 70 | 		if err := s.server.ListenAndServe(); err != nil {
 71 | 			log.Err(err).Msg("Failed to start HTTP server")
 72 | 		}
 73 | 	}()
 74 | 
 75 | 	log.Info().Msg("Starting HTTP server on " + s.ctx.HTTPAddr)
 76 | 
 77 | 	return nil
 78 | }
 79 | 
 80 | func (s *Service) ListenAndServe() error {
 81 | 
 82 | 	if s.ctx.HTTPAddr == "" {
 83 | 		s.ctx.HTTPAddr = DefalutHTTPAddr
 84 | 	}
 85 | 
 86 | 	s.server = &http.Server{
 87 | 		Addr:    s.ctx.HTTPAddr,
 88 | 		Handler: s.router,
 89 | 	}
 90 | 
 91 | 	log.Info().Msg("Starting HTTP server on " + s.ctx.HTTPAddr)
 92 | 	return s.server.ListenAndServe()
 93 | }
 94 | 
 95 | func (s *Service) Stop() error {
 96 | 
 97 | 	if s.server == nil {
 98 | 		return nil
 99 | 	}
100 | 
101 | 	// 使用超时上下文优雅关闭
102 | 	ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
103 | 	defer cancel()
104 | 
105 | 	if err := s.server.Shutdown(ctx); err != nil {
106 | 		log.Debug().Err(err).Msg("Failed to shutdown HTTP server")
107 | 		return nil
108 | 	}
109 | 
110 | 	log.Info().Msg("HTTP server stopped")
111 | 	return nil
112 | }
113 | 
114 | func (s *Service) GetRouter() *gin.Engine {
115 | 	return s.router
116 | }
117 | 


--------------------------------------------------------------------------------
/internal/chatlog/wechat/service.go:
--------------------------------------------------------------------------------
  1 | package wechat
  2 | 
  3 | import (
  4 | 	"context"
  5 | 	"fmt"
  6 | 	"os"
  7 | 	"path/filepath"
  8 | 	"sync"
  9 | 	"time"
 10 | 
 11 | 	"github.com/fsnotify/fsnotify"
 12 | 	"github.com/rs/zerolog/log"
 13 | 
 14 | 	"github.com/sjzar/chatlog/internal/chatlog/ctx"
 15 | 	"github.com/sjzar/chatlog/internal/errors"
 16 | 	"github.com/sjzar/chatlog/internal/wechat"
 17 | 	"github.com/sjzar/chatlog/internal/wechat/decrypt"
 18 | 	"github.com/sjzar/chatlog/pkg/filemonitor"
 19 | 	"github.com/sjzar/chatlog/pkg/util"
 20 | )
 21 | 
 22 | var (
 23 | 	DebounceTime = 1 * time.Second
 24 | 	MaxWaitTime  = 10 * time.Second
 25 | )
 26 | 
 27 | type Service struct {
 28 | 	ctx            *ctx.Context
 29 | 	lastEvents     map[string]time.Time
 30 | 	pendingActions map[string]bool
 31 | 	mutex          sync.Mutex
 32 | 	fm             *filemonitor.FileMonitor
 33 | }
 34 | 
 35 | func NewService(ctx *ctx.Context) *Service {
 36 | 	return &Service{
 37 | 		ctx:            ctx,
 38 | 		lastEvents:     make(map[string]time.Time),
 39 | 		pendingActions: make(map[string]bool),
 40 | 	}
 41 | }
 42 | 
 43 | // GetWeChatInstances returns all running WeChat instances
 44 | func (s *Service) GetWeChatInstances() []*wechat.Account {
 45 | 	wechat.Load()
 46 | 	return wechat.GetAccounts()
 47 | }
 48 | 
 49 | // GetDataKey extracts the encryption key from a WeChat process
 50 | func (s *Service) GetDataKey(info *wechat.Account) (string, error) {
 51 | 	if info == nil {
 52 | 		return "", fmt.Errorf("no WeChat instance selected")
 53 | 	}
 54 | 
 55 | 	key, err := info.GetKey(context.Background())
 56 | 	if err != nil {
 57 | 		return "", err
 58 | 	}
 59 | 
 60 | 	return key, nil
 61 | }
 62 | 
 63 | func (s *Service) StartAutoDecrypt() error {
 64 | 	dbGroup, err := filemonitor.NewFileGroup("wechat", s.ctx.DataDir, `.*\.db



    
    

    
    

    
    
    
    

    
    
    
    




    

    
The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
, []string{"fts"}) 65 | if err != nil { 66 | return err 67 | } 68 | dbGroup.AddCallback(s.DecryptFileCallback) 69 | 70 | s.fm = filemonitor.NewFileMonitor() 71 | s.fm.AddGroup(dbGroup) 72 | if err := s.fm.Start(); err != nil { 73 | log.Debug().Err(err).Msg("failed to start file monitor") 74 | return err 75 | } 76 | return nil 77 | } 78 | 79 | func (s *Service) StopAutoDecrypt() error { 80 | if s.fm != nil { 81 | if err := s.fm.Stop(); err != nil { 82 | return err 83 | } 84 | } 85 | s.fm = nil 86 | return nil 87 | } 88 | 89 | func (s *Service) DecryptFileCallback(event fsnotify.Event) error { 90 | if event.Op.Has(fsnotify.Chmod) || !event.Op.Has(fsnotify.Write) { 91 | return nil 92 | } 93 | 94 | s.mutex.Lock() 95 | s.lastEvents[event.Name] = time.Now() 96 | 97 | if !s.pendingActions[event.Name] { 98 | s.pendingActions[event.Name] = true 99 | s.mutex.Unlock() 100 | go s.waitAndProcess(event.Name) 101 | } else { 102 | s.mutex.Unlock() 103 | } 104 | 105 | return nil 106 | } 107 | 108 | func (s *Service) waitAndProcess(dbFile string) { 109 | start := time.Now() 110 | for { 111 | time.Sleep(DebounceTime) 112 | 113 | s.mutex.Lock() 114 | lastEventTime := s.lastEvents[dbFile] 115 | elapsed := time.Since(lastEventTime) 116 | totalElapsed := time.Since(start) 117 | 118 | if elapsed >= DebounceTime || totalElapsed >= MaxWaitTime { 119 | s.pendingActions[dbFile] = false 120 | s.mutex.Unlock() 121 | 122 | log.Debug().Msgf("Processing file: %s", dbFile) 123 | s.DecryptDBFile(dbFile) 124 | return 125 | } 126 | s.mutex.Unlock() 127 | } 128 | } 129 | 130 | func (s *Service) DecryptDBFile(dbFile string) error { 131 | 132 | decryptor, err := decrypt.NewDecryptor(s.ctx.Platform, s.ctx.Version) 133 | if err != nil { 134 | return err 135 | } 136 | 137 | output := filepath.Join(s.ctx.WorkDir, dbFile[len(s.ctx.DataDir):]) 138 | if err := util.PrepareDir(filepath.Dir(output)); err != nil { 139 | return err 140 | } 141 | 142 | outputTemp := output + ".tmp" 143 | outputFile, err := os.Create(outputTemp) 144 | if err != nil { 145 | return fmt.Errorf("failed to create output file: %v", err) 146 | } 147 | defer func() { 148 | outputFile.Close() 149 | if err := os.Rename(outputTemp, output); err != nil { 150 | log.Debug().Err(err).Msgf("failed to rename %s to %s", outputTemp, output) 151 | } 152 | }() 153 | 154 | if err := decryptor.Decrypt(context.Background(), dbFile, s.ctx.DataKey, outputFile); err != nil { 155 | if err == errors.ErrAlreadyDecrypted { 156 | if data, err := os.ReadFile(dbFile); err == nil { 157 | outputFile.Write(data) 158 | } 159 | return nil 160 | } 161 | log.Err(err).Msgf("failed to decrypt %s", dbFile) 162 | return err 163 | } 164 | 165 | log.Debug().Msgf("Decrypted %s to %s", dbFile, output) 166 | 167 | return nil 168 | } 169 | 170 | func (s *Service) DecryptDBFiles() error { 171 | dbGroup, err := filemonitor.NewFileGroup("wechat", s.ctx.DataDir, `.*\.db
The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
, []string{"fts"}) 172 | if err != nil { 173 | return err 174 | } 175 | 176 | dbFiles, err := dbGroup.List() 177 | if err != nil { 178 | return err 179 | } 180 | 181 | for _, dbFile := range dbFiles { 182 | if err := s.DecryptDBFile(dbFile); err != nil { 183 | log.Debug().Msgf("DecryptDBFile %s failed: %v", dbFile, err) 184 | continue 185 | } 186 | } 187 | 188 | return nil 189 | } 190 | -------------------------------------------------------------------------------- /internal/errors/errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "net/http" 7 | "runtime" 8 | "strings" 9 | 10 | "github.com/gin-gonic/gin" 11 | ) 12 | 13 | type Error struct { 14 | Message string `json:"message"` // 错误消息 15 | Cause error `json:"-"` // 原始错误 16 | Code int `json:"-"` // HTTP Code 17 | Stack []string `json:"-"` // 错误堆栈 18 | } 19 | 20 | func (e *Error) Error() string { 21 | if e.Cause != nil { 22 | return fmt.Sprintf("%s: %v", e.Message, e.Cause) 23 | } 24 | return fmt.Sprintf("%s", e.Message) 25 | } 26 | 27 | func (e *Error) String() string { 28 | return e.Error() 29 | } 30 | 31 | func (e *Error) Unwrap() error { 32 | return e.Cause 33 | } 34 | 35 | func (e *Error) WithStack() *Error { 36 | const depth = 32 37 | var pcs [depth]uintptr 38 | n := runtime.Callers(2, pcs[:]) 39 | frames := runtime.CallersFrames(pcs[:n]) 40 | 41 | stack := make([]string, 0, n) 42 | for { 43 | frame, more := frames.Next() 44 | if !strings.Contains(frame.File, "runtime/") { 45 | stack = append(stack, fmt.Sprintf("%s:%d %s", frame.File, frame.Line, frame.Function)) 46 | } 47 | if !more { 48 | break 49 | } 50 | } 51 | 52 | e.Stack = stack 53 | return e 54 | } 55 | 56 | func New(cause error, code int, message string) *Error { 57 | return &Error{ 58 | Message: message, 59 | Cause: cause, 60 | Code: code, 61 | } 62 | } 63 | 64 | func Newf(cause error, code int, format string, args ...interface{}) *Error { 65 | return &Error{ 66 | Message: fmt.Sprintf(format, args...), 67 | Cause: cause, 68 | Code: code, 69 | } 70 | } 71 | 72 | func Wrap(err error, message string, code int) *Error { 73 | if err == nil { 74 | return nil 75 | } 76 | 77 | if appErr, ok := err.(*Error); ok { 78 | return &Error{ 79 | Message: message, 80 | Cause: appErr.Cause, 81 | Code: appErr.Code, 82 | Stack: appErr.Stack, 83 | } 84 | } 85 | 86 | return New(err, code, message) 87 | } 88 | 89 | func GetCode(err error) int { 90 | if err == nil { 91 | return http.StatusOK 92 | } 93 | 94 | var appErr *Error 95 | if errors.As(err, &appErr) { 96 | return appErr.Code 97 | } 98 | 99 | return http.StatusInternalServerError 100 | } 101 | 102 | func RootCause(err error) error { 103 | for err != nil { 104 | unwrapped := errors.Unwrap(err) 105 | if unwrapped == nil { 106 | return err 107 | } 108 | err = unwrapped 109 | } 110 | return err 111 | } 112 | 113 | func Err(c *gin.Context, err error) { 114 | if appErr, ok := err.(*Error); ok { 115 | c.JSON(appErr.Code, appErr.Error()) 116 | return 117 | } 118 | 119 | c.JSON(http.StatusInternalServerError, err.Error()) 120 | } 121 | -------------------------------------------------------------------------------- /internal/errors/http_errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "net/http" 4 | 5 | func InvalidArg(arg string) error { 6 | return Newf(nil, http.StatusBadRequest, "invalid argument: %s", arg) 7 | } 8 | 9 | func HTTPShutDown(cause error) error { 10 | return Newf(cause, http.StatusInternalServerError, "http server shut down") 11 | } 12 | -------------------------------------------------------------------------------- /internal/errors/middleware.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "net/http" 5 | "runtime/debug" 6 | 7 | "github.com/gin-gonic/gin" 8 | "github.com/google/uuid" 9 | "github.com/rs/zerolog/log" 10 | ) 11 | 12 | // ErrorHandlerMiddleware 是一个 Gin 中间件,用于统一处理请求过程中的错误 13 | // 它会为每个请求生成一个唯一的请求 ID,并在错误发生时将其添加到错误响应中 14 | func ErrorHandlerMiddleware() gin.HandlerFunc { 15 | return func(c *gin.Context) { 16 | // 生成请求 ID 17 | requestID := uuid.New().String() 18 | c.Set("RequestID", requestID) 19 | c.Header("X-Request-ID", requestID) 20 | 21 | // 处理请求 22 | c.Next() 23 | 24 | // 检查是否有错误 25 | if len(c.Errors) > 0 { 26 | // 获取第一个错误 27 | err := c.Errors[0].Err 28 | 29 | // 使用 Err 函数处理错误响应 30 | Err(c, err) 31 | 32 | // 已经处理过错误,不需要继续 33 | c.Abort() 34 | } 35 | } 36 | } 37 | 38 | // RecoveryMiddleware 是一个 Gin 中间件,用于从 panic 恢复并返回 500 错误 39 | func RecoveryMiddleware() gin.HandlerFunc { 40 | return func(c *gin.Context) { 41 | defer func() { 42 | if r := recover(); r != nil { 43 | 44 | // 创建内部服务器错误 45 | var err *Error 46 | switch v := r.(type) { 47 | case error: 48 | err = New(v, http.StatusInternalServerError, "panic recovered") 49 | default: 50 | err = Newf(nil, http.StatusInternalServerError, "panic recovered: %v", r) 51 | } 52 | 53 | // 记录错误日志 54 | log.Err(err).Msgf("PANIC RECOVERED\n%s", string(debug.Stack())) 55 | 56 | // 返回 500 错误 57 | c.JSON(http.StatusInternalServerError, err) 58 | c.Abort() 59 | } 60 | }() 61 | 62 | c.Next() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /internal/errors/os_errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "net/http" 4 | 5 | func OpenFileFailed(path string, cause error) *Error { 6 | return Newf(cause, http.StatusInternalServerError, "failed to open file: %s", path).WithStack() 7 | } 8 | 9 | func StatFileFailed(path string, cause error) *Error { 10 | return Newf(cause, http.StatusInternalServerError, "failed to stat file: %s", path).WithStack() 11 | } 12 | 13 | func ReadFileFailed(path string, cause error) *Error { 14 | return Newf(cause, http.StatusInternalServerError, "failed to read file: %s", path).WithStack() 15 | } 16 | 17 | func IncompleteRead(cause error) *Error { 18 | return New(cause, http.StatusInternalServerError, "incomplete header read during decryption").WithStack() 19 | } 20 | 21 | func WriteOutputFailed(cause error) *Error { 22 | return New(cause, http.StatusInternalServerError, "failed to write output").WithStack() 23 | } 24 | -------------------------------------------------------------------------------- /internal/errors/wechat_errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import "net/http" 4 | 5 | var ( 6 | ErrAlreadyDecrypted = New(nil, http.StatusBadRequest, "database file is already decrypted") 7 | ErrDecryptHashVerificationFailed = New(nil, http.StatusBadRequest, "hash verification failed during decryption") 8 | ErrDecryptIncorrectKey = New(nil, http.StatusBadRequest, "incorrect decryption key") 9 | ErrDecryptOperationCanceled = New(nil, http.StatusBadRequest, "decryption operation was canceled") 10 | ErrNoMemoryRegionsFound = New(nil, http.StatusBadRequest, "no memory regions found") 11 | ErrReadMemoryTimeout = New(nil, http.StatusInternalServerError, "read memory timeout") 12 | ErrWeChatOffline = New(nil, http.StatusBadRequest, "WeChat is offline") 13 | ErrSIPEnabled = New(nil, http.StatusBadRequest, "SIP is enabled") 14 | ErrValidatorNotSet = New(nil, http.StatusBadRequest, "validator not set") 15 | ErrNoValidKey = New(nil, http.StatusBadRequest, "no valid key found") 16 | ErrWeChatDLLNotFound = New(nil, http.StatusBadRequest, "WeChatWin.dll module not found") 17 | ) 18 | 19 | func PlatformUnsupported(platform string, version int) *Error { 20 | return Newf(nil, http.StatusBadRequest, "unsupported platform: %s v%d", platform, version).WithStack() 21 | } 22 | 23 | func DecryptCreateCipherFailed(cause error) *Error { 24 | return New(cause, http.StatusInternalServerError, "failed to create cipher").WithStack() 25 | } 26 | 27 | func DecodeKeyFailed(cause error) *Error { 28 | return New(cause, http.StatusBadRequest, "failed to decode hex key").WithStack() 29 | } 30 | 31 | func CreatePipeFileFailed(cause error) *Error { 32 | return New(cause, http.StatusInternalServerError, "failed to create pipe file").WithStack() 33 | } 34 | 35 | func OpenPipeFileFailed(cause error) *Error { 36 | return New(cause, http.StatusInternalServerError, "failed to open pipe file").WithStack() 37 | } 38 | 39 | func ReadPipeFileFailed(cause error) *Error { 40 | return New(cause, http.StatusInternalServerError, "failed to read from pipe file").WithStack() 41 | } 42 | 43 | func RunCmdFailed(cause error) *Error { 44 | return New(cause, http.StatusInternalServerError, "failed to run command").WithStack() 45 | } 46 | 47 | func ReadMemoryFailed(cause error) *Error { 48 | return New(cause, http.StatusInternalServerError, "failed to read memory").WithStack() 49 | } 50 | 51 | func OpenProcessFailed(cause error) *Error { 52 | return New(cause, http.StatusInternalServerError, "failed to open process").WithStack() 53 | } 54 | 55 | func WeChatAccountNotFound(name string) *Error { 56 | return Newf(nil, http.StatusBadRequest, "WeChat account not found: %s", name).WithStack() 57 | } 58 | 59 | func WeChatAccountNotOnline(name string) *Error { 60 | return Newf(nil, http.StatusBadRequest, "WeChat account is not online: %s", name).WithStack() 61 | } 62 | 63 | func RefreshProcessStatusFailed(cause error) *Error { 64 | return New(cause, http.StatusInternalServerError, "failed to refresh process status").WithStack() 65 | } 66 | -------------------------------------------------------------------------------- /internal/errors/wechatdb_errors.go: -------------------------------------------------------------------------------- 1 | package errors 2 | 3 | import ( 4 | "net/http" 5 | "time" 6 | ) 7 | 8 | var ( 9 | ErrTalkerEmpty = New(nil, http.StatusBadRequest, "talker empty").WithStack() 10 | ErrKeyEmpty = New(nil, http.StatusBadRequest, "key empty").WithStack() 11 | ErrMediaNotFound = New(nil, http.StatusNotFound, "media not found").WithStack() 12 | ErrKeyLengthMust32 = New(nil, http.StatusBadRequest, "key length must be 32 bytes").WithStack() 13 | ) 14 | 15 | // 数据库初始化相关错误 16 | func DBFileNotFound(path, pattern string, cause error) *Error { 17 | return Newf(cause, http.StatusNotFound, "db file not found %s: %s", path, pattern).WithStack() 18 | } 19 | 20 | func DBConnectFailed(path string, cause error) *Error { 21 | return Newf(cause, http.StatusInternalServerError, "db connect failed: %s", path).WithStack() 22 | } 23 | 24 | func DBInitFailed(cause error) *Error { 25 | return New(cause, http.StatusInternalServerError, "db init failed").WithStack() 26 | } 27 | 28 | func TalkerNotFound(talker string) *Error { 29 | return Newf(nil, http.StatusNotFound, "talker not found: %s", talker).WithStack() 30 | } 31 | 32 | func DBCloseFailed(cause error) *Error { 33 | return New(cause, http.StatusInternalServerError, "db close failed").WithStack() 34 | } 35 | 36 | func QueryFailed(query string, cause error) *Error { 37 | return Newf(cause, http.StatusInternalServerError, "query failed: %s", query).WithStack() 38 | } 39 | 40 | func ScanRowFailed(cause error) *Error { 41 | return New(cause, http.StatusInternalServerError, "scan row failed").WithStack() 42 | } 43 | 44 | func TimeRangeNotFound(start, end time.Time) *Error { 45 | return Newf(nil, http.StatusNotFound, "time range not found: %s - %s", start, end).WithStack() 46 | } 47 | 48 | func MediaTypeUnsupported(_type string) *Error { 49 | return Newf(nil, http.StatusBadRequest, "unsupported media type: %s", _type).WithStack() 50 | } 51 | 52 | func ChatRoomNotFound(key string) *Error { 53 | return Newf(nil, http.StatusNotFound, "chat room not found: %s", key).WithStack() 54 | } 55 | 56 | func ContactNotFound(key string) *Error { 57 | return Newf(nil, http.StatusNotFound, "contact not found: %s", key).WithStack() 58 | } 59 | 60 | func InitCacheFailed(cause error) *Error { 61 | return New(cause, http.StatusInternalServerError, "init cache failed").WithStack() 62 | } 63 | 64 | func FileGroupNotFound(name string) *Error { 65 | return Newf(nil, http.StatusNotFound, "file group not found: %s", name).WithStack() 66 | } 67 | -------------------------------------------------------------------------------- /internal/mcp/error.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | // enum ErrorCode { 8 | // // Standard JSON-RPC error codes 9 | // ParseError = -32700, 10 | // InvalidRequest = -32600, 11 | // MethodNotFound = -32601, 12 | // InvalidParams = -32602, 13 | // InternalError = -32603 14 | // } 15 | 16 | // Error 17 | type Error struct { 18 | Code int `json:"code"` 19 | Message string `json:"message"` 20 | Data interface{} `json:"data,omitempty"` 21 | } 22 | 23 | var ( 24 | ErrParseError = &Error{Code: -32700, Message: "Parse error"} 25 | ErrInvalidRequest = &Error{Code: -32600, Message: "Invalid Request"} 26 | ErrMethodNotFound = &Error{Code: -32601, Message: "Method not found"} 27 | ErrInvalidParams = &Error{Code: -32602, Message: "Invalid params"} 28 | ErrInternalError = &Error{Code: -32603, Message: "Internal error"} 29 | 30 | ErrInvalidSessionID = &Error{Code: 400, Message: "Invalid session ID"} 31 | ErrSessionNotFound = &Error{Code: 404, Message: "Could not find session"} 32 | ErrTooManyRequests = &Error{Code: 429, Message: "Too many requests"} 33 | ) 34 | 35 | func (e *Error) Error() string { 36 | return fmt.Sprintf("%d: %s", e.Code, e.Message) 37 | } 38 | 39 | func (e *Error) JsonRPC() Response { 40 | return Response{ 41 | JsonRPC: JsonRPCVersion, 42 | Error: e, 43 | } 44 | } 45 | 46 | func NewErrorResponse(id interface{}, code int, err error) *Response { 47 | return &Response{ 48 | JsonRPC: JsonRPCVersion, 49 | ID: id, 50 | Error: &Error{ 51 | Code: code, 52 | Message: err.Error(), 53 | }, 54 | } 55 | } 56 | -------------------------------------------------------------------------------- /internal/mcp/initialize.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | const ( 4 | MethodInitialize = "initialize" 5 | MethodPing = "ping" 6 | ProtocolVersion = "2024-11-05" 7 | ) 8 | 9 | // { 10 | // "method": "initialize", 11 | // "params": { 12 | // "protocolVersion": "2024-11-05", 13 | // "capabilities": { 14 | // "sampling": {}, 15 | // "roots": { 16 | // "listChanged": true 17 | // } 18 | // }, 19 | // "clientInfo": { 20 | // "name": "mcp-inspector", 21 | // "version": "0.0.1" 22 | // } 23 | // }, 24 | // "jsonrpc": "2.0", 25 | // "id": 0 26 | // } 27 | type InitializeRequest struct { 28 | ProtocolVersion string `json:"protocolVersion"` 29 | Capabilities M `json:"capabilities"` 30 | ClientInfo *ClientInfo `json:"clientInfo"` 31 | } 32 | 33 | type ClientInfo struct { 34 | Name string `json:"name"` 35 | Version string `json:"version"` 36 | } 37 | 38 | // { 39 | // "jsonrpc": "2.0", 40 | // "id": 0, 41 | // "result": { 42 | // "protocolVersion": "2024-11-05", 43 | // "capabilities": { 44 | // "experimental": {}, 45 | // "prompts": { 46 | // "listChanged": false 47 | // }, 48 | // "resources": { 49 | // "subscribe": false, 50 | // "listChanged": false 51 | // }, 52 | // "tools": { 53 | // "listChanged": false 54 | // } 55 | // }, 56 | // "serverInfo": { 57 | // "name": "weather", 58 | // "version": "1.4.1" 59 | // } 60 | // } 61 | // } 62 | type InitializeResponse struct { 63 | ProtocolVersion string `json:"protocolVersion"` 64 | Capabilities M `json:"capabilities"` 65 | ServerInfo ServerInfo `json:"serverInfo"` 66 | } 67 | 68 | type ServerInfo struct { 69 | Name string `json:"name"` 70 | Version string `json:"version"` 71 | } 72 | 73 | var DefaultCapabilities = M{ 74 | "experimental": M{}, 75 | "prompts": M{"listChanged": false}, 76 | "resources": M{"subscribe": false, "listChanged": false}, 77 | "tools": M{"listChanged": false}, 78 | } 79 | -------------------------------------------------------------------------------- /internal/mcp/jsonrpc.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | const ( 4 | JsonRPCVersion = "2.0" 5 | ) 6 | 7 | // Documents: https://modelcontextprotocol.io/docs/concepts/transports 8 | 9 | // Request 10 | // 11 | // { 12 | // jsonrpc: "2.0", 13 | // id: number | string, 14 | // method: string, 15 | // params?: object 16 | // } 17 | type Request struct { 18 | JsonRPC string `json:"jsonrpc"` 19 | ID interface{} `json:"id"` 20 | Method string `json:"method"` 21 | Params interface{} `json:"params,omitempty"` 22 | } 23 | 24 | // Response 25 | // 26 | // { 27 | // jsonrpc: "2.0", 28 | // id: number | string, 29 | // result?: object, 30 | // error?: { 31 | // code: number, 32 | // message: string, 33 | // data?: unknown 34 | // } 35 | // } 36 | type Response struct { 37 | JsonRPC string `json:"jsonrpc"` 38 | ID interface{} `json:"id"` 39 | Result interface{} `json:"result,omitempty"` 40 | Error *Error `json:"error,omitempty"` 41 | } 42 | 43 | func NewResponse(id interface{}, result interface{}) *Response { 44 | return &Response{ 45 | JsonRPC: JsonRPCVersion, 46 | ID: id, 47 | Result: result, 48 | } 49 | } 50 | 51 | // Notifications 52 | // 53 | // { 54 | // jsonrpc: "2.0", 55 | // method: string, 56 | // params?: object 57 | // } 58 | type Notification struct { 59 | JsonRPC string `json:"jsonrpc"` 60 | Method string `json:"method"` 61 | Params interface{} `json:"params,omitempty"` 62 | } 63 | -------------------------------------------------------------------------------- /internal/mcp/mcp.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "io" 5 | "net/http" 6 | "sync" 7 | 8 | "github.com/gin-gonic/gin" 9 | "github.com/google/uuid" 10 | "github.com/rs/zerolog/log" 11 | ) 12 | 13 | const ( 14 | ProcessChanCap = 1000 15 | ) 16 | 17 | type MCP struct { 18 | sessions map[string]*Session 19 | sessionMu sync.Mutex 20 | 21 | ProcessChan chan ProcessCtx 22 | } 23 | 24 | func NewMCP() *MCP { 25 | return &MCP{ 26 | sessions: make(map[string]*Session), 27 | ProcessChan: make(chan ProcessCtx, ProcessChanCap), 28 | } 29 | } 30 | 31 | func (m *MCP) HandleSSE(c *gin.Context) { 32 | id := uuid.New().String() 33 | m.sessionMu.Lock() 34 | m.sessions[id] = NewSession(c, id) 35 | m.sessionMu.Unlock() 36 | 37 | c.Stream(func(w io.Writer) bool { 38 | <-c.Request.Context().Done() 39 | return false 40 | }) 41 | 42 | m.sessionMu.Lock() 43 | delete(m.sessions, id) 44 | m.sessionMu.Unlock() 45 | } 46 | 47 | func (m *MCP) GetSession(id string) *Session { 48 | m.sessionMu.Lock() 49 | defer m.sessionMu.Unlock() 50 | return m.sessions[id] 51 | } 52 | 53 | func (m *MCP) HandleMessages(c *gin.Context) { 54 | 55 | // panic("xxx") 56 | 57 | // 啊这, 一个 sessionid 有 3 种写法 session_id, sessionId, sessionid 58 | // 官方 SDK 是 session_id: https://github.com/modelcontextprotocol/python-sdk/blob/c897868/src/mcp/server/sse.py#L98 59 | // 写的是 sessionId: https://github.com/modelcontextprotocol/inspector/blob/aeaf32f/server/src/index.ts#L157 60 | 61 | sessionID := c.Query("session_id") 62 | if sessionID == "" { 63 | sessionID = c.Query("sessionId") 64 | } 65 | if sessionID == "" { 66 | sessionID = c.Param("sessionid") 67 | } 68 | if sessionID == "" { 69 | c.JSON(http.StatusBadRequest, ErrInvalidSessionID.JsonRPC()) 70 | c.Abort() 71 | return 72 | } 73 | 74 | session := m.GetSession(sessionID) 75 | if session == nil { 76 | c.JSON(http.StatusNotFound, ErrSessionNotFound.JsonRPC()) 77 | c.Abort() 78 | return 79 | } 80 | 81 | var req Request 82 | if err := c.ShouldBindJSON(&req); err != nil { 83 | c.JSON(http.StatusBadRequest, ErrInvalidRequest.JsonRPC()) 84 | c.Abort() 85 | return 86 | } 87 | 88 | log.Debug().Msgf("session: %s, request: %s", sessionID, req) 89 | select { 90 | case m.ProcessChan <- ProcessCtx{Session: session, Request: &req}: 91 | default: 92 | c.JSON(http.StatusTooManyRequests, ErrTooManyRequests.JsonRPC()) 93 | c.Abort() 94 | return 95 | } 96 | 97 | c.String(http.StatusAccepted, "Accepted") 98 | } 99 | 100 | func (m *MCP) Close() { 101 | close(m.ProcessChan) 102 | } 103 | 104 | type ProcessCtx struct { 105 | Session *Session 106 | Request *Request 107 | } 108 | -------------------------------------------------------------------------------- /internal/mcp/prompt.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | // Document: https://modelcontextprotocol.io/docs/concepts/prompts 4 | 5 | const ( 6 | // Client => Server 7 | MethodPromptsList = "prompts/list" 8 | MethodPromptsGet = "prompts/get" 9 | ) 10 | 11 | // Prompt 12 | // 13 | // { 14 | // name: string; // Unique identifier for the prompt 15 | // description?: string; // Human-readable description 16 | // arguments?: [ // Optional list of arguments 17 | // { 18 | // name: string; // Argument identifier 19 | // description?: string; // Argument description 20 | // required?: boolean; // Whether argument is required 21 | // } 22 | // ] 23 | // } 24 | type Prompt struct { 25 | Name string `json:"name"` 26 | Description string `json:"description,omitempty"` 27 | Arguments []PromptArgument `json:"arguments,omitempty"` 28 | } 29 | 30 | type PromptArgument struct { 31 | Name string `json:"name"` 32 | Description string `json:"description,omitempty"` 33 | Required bool `json:"required,omitempty"` 34 | } 35 | 36 | // ListPrompts 37 | // 38 | // { 39 | // prompts: [ 40 | // { 41 | // name: "analyze-code", 42 | // description: "Analyze code for potential improvements", 43 | // arguments: [ 44 | // { 45 | // name: "language", 46 | // description: "Programming language", 47 | // required: true 48 | // } 49 | // ] 50 | // } 51 | // ] 52 | // } 53 | type PromptsListResponse struct { 54 | Prompts []Prompt `json:"prompts"` 55 | } 56 | 57 | // Use Prompt 58 | // Request 59 | // 60 | // { 61 | // method: "prompts/get", 62 | // params: { 63 | // name: "analyze-code", 64 | // arguments: { 65 | // language: "python" 66 | // } 67 | // } 68 | // } 69 | // 70 | // Response 71 | // 72 | // { 73 | // description: "Analyze Python code for potential improvements", 74 | // messages: [ 75 | // { 76 | // role: "user", 77 | // content: { 78 | // type: "text", 79 | // text: "Please analyze the following Python code for potential improvements:\n\n```python\ndef calculate_sum(numbers):\n total = 0\n for num in numbers:\n total = total + num\n return total\n\nresult = calculate_sum([1, 2, 3, 4, 5])\nprint(result)\n```" 80 | // } 81 | // } 82 | // ] 83 | // } 84 | type PromptsGetRequest struct { 85 | Name string `json:"name"` 86 | Arguments M `json:"arguments"` 87 | } 88 | 89 | type PromptsGetResponse struct { 90 | Description string `json:"description"` 91 | Messages []PromptMessage `json:"messages"` 92 | } 93 | 94 | type PromptMessage struct { 95 | Role string `json:"role"` 96 | Content PromptContent `json:"content"` 97 | } 98 | 99 | type PromptContent struct { 100 | Type string `json:"type"` 101 | Text string `json:"text,omitempty"` 102 | Resource interface{} `json:"resource,omitempty"` // Resource or ResourceTemplate 103 | } 104 | 105 | // { 106 | // "messages": [ 107 | // { 108 | // "role": "user", 109 | // "content": { 110 | // "type": "text", 111 | // "text": "Analyze these system logs and the code file for any issues:" 112 | // } 113 | // }, 114 | // { 115 | // "role": "user", 116 | // "content": { 117 | // "type": "resource", 118 | // "resource": { 119 | // "uri": "logs://recent?timeframe=1h", 120 | // "text": "[2024-03-14 15:32:11] ERROR: Connection timeout in network.py:127\n[2024-03-14 15:32:15] WARN: Retrying connection (attempt 2/3)\n[2024-03-14 15:32:20] ERROR: Max retries exceeded", 121 | // "mimeType": "text/plain" 122 | // } 123 | // } 124 | // }, 125 | // { 126 | // "role": "user", 127 | // "content": { 128 | // "type": "resource", 129 | // "resource": { 130 | // "uri": "file:///path/to/code.py", 131 | // "text": "def connect_to_service(timeout=30):\n retries = 3\n for attempt in range(retries):\n try:\n return establish_connection(timeout)\n except TimeoutError:\n if attempt == retries - 1:\n raise\n time.sleep(5)\n\ndef establish_connection(timeout):\n # Connection implementation\n pass", 132 | // "mimeType": "text/x-python" 133 | // } 134 | // } 135 | // } 136 | // ] 137 | // } 138 | -------------------------------------------------------------------------------- /internal/mcp/resource.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | // Document: https://modelcontextprotocol.io/docs/concepts/resources 4 | 5 | const ( 6 | // Client => Server 7 | MethodResourcesList = "resources/list" 8 | MethodResourcesTemplateList = "resources/templates/list" 9 | MethodResourcesRead = "resources/read" 10 | MethodResourcesSubscribe = "resources/subscribe" 11 | MethodResourcesUnsubscribe = "resources/unsubscribe" 12 | 13 | // Server => Client 14 | NotificationResourcesListChanged = "notifications/resources/list_changed" 15 | NofiticationResourcesUpdated = "notifications/resources/updated" 16 | ) 17 | 18 | // Direct resources 19 | // 20 | // { 21 | // uri: string; // Unique identifier for the resource 22 | // name: string; // Human-readable name 23 | // description?: string; // Optional description 24 | // mimeType?: string; // Optional MIME type 25 | // } 26 | type Resource struct { 27 | URI string `json:"uri"` 28 | Name string `json:"name"` 29 | Description string `json:"description,omitempty"` 30 | MimeType string `json:"mimeType,omitempty"` 31 | } 32 | 33 | // Resource templates 34 | // 35 | // { 36 | // uriTemplate: string; // URI template following RFC 6570 37 | // name: string; // Human-readable name for this type 38 | // description?: string; // Optional description 39 | // mimeType?: string; // Optional MIME type for all matching resources 40 | // } 41 | type ResourceTemplate struct { 42 | URITemplate string `json:"uriTemplate"` 43 | Name string `json:"name"` 44 | Description string `json:"description,omitempty"` 45 | MimeType string `json:"mimeType,omitempty"` 46 | } 47 | 48 | // Reading resources 49 | // { 50 | // contents: [ 51 | // { 52 | // uri: string; // The URI of the resource 53 | // mimeType?: string; // Optional MIME type 54 | 55 | // // One of: 56 | // text?: string; // For text resources 57 | // blob?: string; // For binary resources (base64 encoded) 58 | // } 59 | // ] 60 | // } 61 | type ReadingResource struct { 62 | Contents []ReadingResourceContent `json:"contents"` 63 | } 64 | 65 | type ResourcesReadRequest struct { 66 | URI string `json:"uri"` 67 | } 68 | 69 | type ReadingResourceContent struct { 70 | URI string `json:"uri"` 71 | MimeType string `json:"mimeType,omitempty"` 72 | Text string `json:"text,omitempty"` 73 | Blob string `json:"blob,omitempty"` 74 | } 75 | -------------------------------------------------------------------------------- /internal/mcp/session.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "encoding/json" 5 | "io" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | type Session struct { 11 | id string 12 | w io.Writer 13 | c *ClientInfo 14 | } 15 | 16 | func NewSession(c *gin.Context, id string) *Session { 17 | return &Session{ 18 | id: id, 19 | w: NewSSEWriter(c, id), 20 | } 21 | } 22 | 23 | func (s *Session) Write(p []byte) (n int, err error) { 24 | return s.w.Write(p) 25 | } 26 | 27 | func (s *Session) WriteError(req *Request, err error) { 28 | resp := NewErrorResponse(req.ID, 500, err) 29 | b, err := json.Marshal(resp) 30 | if err != nil { 31 | return 32 | } 33 | s.Write(b) 34 | } 35 | 36 | func (s *Session) WriteResponse(req *Request, data interface{}) error { 37 | resp := NewResponse(req.ID, data) 38 | b, err := json.Marshal(resp) 39 | if err != nil { 40 | return err 41 | } 42 | s.Write(b) 43 | return nil 44 | } 45 | 46 | func (s *Session) SaveClientInfo(c *ClientInfo) { 47 | s.c = c 48 | } 49 | -------------------------------------------------------------------------------- /internal/mcp/sse.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | import ( 4 | "fmt" 5 | "time" 6 | 7 | "github.com/gin-gonic/gin" 8 | ) 9 | 10 | const ( 11 | SSEPingIntervalS = 30 12 | SSEMessageChanCap = 100 13 | SSEContentType = "text/event-stream; charset=utf-8" 14 | ) 15 | 16 | type SSEWriter struct { 17 | id string 18 | c *gin.Context 19 | } 20 | 21 | func NewSSEWriter(c *gin.Context, id string) *SSEWriter { 22 | c.Writer.Header().Set("Content-Type", SSEContentType) 23 | c.Writer.Header().Set("Cache-Control", "no-cache") 24 | c.Writer.Header().Set("Connection", "keep-alive") 25 | c.Writer.Header().Set("Access-Control-Allow-Origin", "*") 26 | c.Writer.Flush() 27 | 28 | w := &SSEWriter{ 29 | id: id, 30 | c: c, 31 | } 32 | w.WriteEndpoing() 33 | go w.ping() 34 | return w 35 | } 36 | 37 | func (w *SSEWriter) Write(p []byte) (n int, err error) { 38 | w.WriteMessage(string(p)) 39 | return len(p), nil 40 | } 41 | 42 | func (w *SSEWriter) WriteMessage(data string) { 43 | w.WriteEvent("message", data) 44 | } 45 | 46 | func (w *SSEWriter) WriteEvent(event string, data string) { 47 | w.c.Writer.WriteString(fmt.Sprintf("event: %s\n", event)) 48 | w.c.Writer.WriteString(fmt.Sprintf("data: %s\n\n", data)) 49 | w.c.Writer.Flush() 50 | } 51 | 52 | func (w *SSEWriter) ping() { 53 | for { 54 | select { 55 | case <-time.After(time.Second * SSEPingIntervalS): 56 | w.writePing() 57 | case <-w.c.Request.Context().Done(): 58 | return 59 | } 60 | } 61 | } 62 | 63 | // WriteEndpoing 64 | // event: endpoint 65 | // data: /message?sessionId=285d67ee-1c17-40d9-ab03-173d5ff48419 66 | func (w *SSEWriter) WriteEndpoing() { 67 | w.c.Writer.WriteString(fmt.Sprintf("event: endpoint\n")) 68 | w.c.Writer.WriteString(fmt.Sprintf("data: /message?sessionId=%s\n\n", w.id)) 69 | w.c.Writer.Flush() 70 | } 71 | 72 | // WritePing 73 | // : ping - 2025-03-16 06:41:51.280928+00:00 74 | func (w *SSEWriter) writePing() { 75 | w.c.Writer.WriteString(fmt.Sprintf(": ping - %s\n\n", time.Now().Format("2006-01-02 15:04:05.999999-07:00"))) 76 | } 77 | 78 | // SSE Session 79 | // 维持一个 SSE 连接的会话 80 | // 会话中包含了 SSE 连接的 ID,事件通道,停止通道 81 | // 事件通道用于发送事件,停止通道用于停止会话 82 | // 需要轮询发送 ping 事件以保持连接 83 | type SSESession struct { 84 | SessionID string 85 | Events map[string]chan string 86 | Stop chan bool 87 | 88 | c *gin.Context 89 | } 90 | 91 | func NewSSESession(c *gin.Context) *SSESession { 92 | return &SSESession{c: c} 93 | } 94 | func (s *SSESession) SendEvent(event string, data string) { 95 | s.c.SSEvent(event, data) 96 | } 97 | 98 | func (s *SSESession) Close() { 99 | close(s.Stop) 100 | } 101 | 102 | // Event 103 | // request: 104 | // POST /messages?sesessionId=? 105 | // '{"method":"prompts/list","params":{},"jsonrpc":"2.0","id":3}' 106 | // 107 | // response: 108 | // GET /sse 109 | // event: message 110 | // data: {"jsonrpc":"2.0","id":3,"result":{"prompts":[]}} 111 | 112 | // { 113 | // "jsonrpc": "2.0", 114 | // "id": 1, 115 | // "result": { 116 | // "tools": [ 117 | // { 118 | // "name": "get_alerts", 119 | // "description": "Get weather alerts for a US state.\n\n Args:\n state: Two-letter US state code (e.g. CA, NY)\n ", 120 | // "inputSchema": { 121 | // "properties": { 122 | // "state": { 123 | // "title": "State", 124 | // "type": "string" 125 | // } 126 | // }, 127 | // "required": [ 128 | // "state" 129 | // ], 130 | // "title": "get_alertsArguments", 131 | // "type": "object" 132 | // } 133 | // }, 134 | // { 135 | // "name": "get_forecast", 136 | // "description": "Get weather forecast for a location.\n\n Args:\n latitude: Latitude of the location\n longitude: Longitude of the location\n ", 137 | // "inputSchema": { 138 | // "properties": { 139 | // "latitude": { 140 | // "title": "Latitude", 141 | // "type": "number" 142 | // }, 143 | // "longitude": { 144 | // "title": "Longitude", 145 | // "type": "number" 146 | // } 147 | // }, 148 | // "required": [ 149 | // "latitude", 150 | // "longitude" 151 | // ], 152 | // "title": "get_forecastArguments", 153 | // "type": "object" 154 | // } 155 | // } 156 | // ] 157 | // } 158 | // } 159 | 160 | // PING 161 | -------------------------------------------------------------------------------- /internal/mcp/stdio.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | -------------------------------------------------------------------------------- /internal/mcp/tool.go: -------------------------------------------------------------------------------- 1 | package mcp 2 | 3 | // Document: https://modelcontextprotocol.io/docs/concepts/tools 4 | 5 | const ( 6 | // Client => Server 7 | MethodToolsList = "tools/list" 8 | MethodToolsCall = "tools/call" 9 | ) 10 | 11 | type M map[string]interface{} 12 | 13 | // Tool 14 | // 15 | // { 16 | // name: string; // Unique identifier for the tool 17 | // description?: string; // Human-readable description 18 | // inputSchema: { // JSON Schema for the tool's parameters 19 | // type: "object", 20 | // properties: { ... } // Tool-specific parameters 21 | // } 22 | // } 23 | // 24 | // { 25 | // name: "analyze_csv", 26 | // description: "Analyze a CSV file", 27 | // inputSchema: { 28 | // type: "object", 29 | // properties: { 30 | // filepath: { type: "string" }, 31 | // operations: { 32 | // type: "array", 33 | // items: { 34 | // enum: ["sum", "average", "count"] 35 | // } 36 | // } 37 | // } 38 | // } 39 | // } 40 | // 41 | // { 42 | // "jsonrpc": "2.0", 43 | // "id": 1, 44 | // "result": { 45 | // "tools": [ 46 | // { 47 | // "name": "get_alerts", 48 | // "description": "Get weather alerts for a US state.\n\n Args:\n state: Two-letter US state code (e.g. CA, NY)\n ", 49 | // "inputSchema": { 50 | // "properties": { 51 | // "state": { 52 | // "title": "State", 53 | // "type": "string" 54 | // } 55 | // }, 56 | // "required": [ 57 | // "state" 58 | // ], 59 | // "title": "get_alertsArguments", 60 | // "type": "object" 61 | // } 62 | // }, 63 | // { 64 | // "name": "get_forecast", 65 | // "description": "Get weather forecast for a location.\n\n Args:\n latitude: Latitude of the location\n longitude: Longitude of the location\n ", 66 | // "inputSchema": { 67 | // "properties": { 68 | // "latitude": { 69 | // "title": "Latitude", 70 | // "type": "number" 71 | // }, 72 | // "longitude": { 73 | // "title": "Longitude", 74 | // "type": "number" 75 | // } 76 | // }, 77 | // "required": [ 78 | // "latitude", 79 | // "longitude" 80 | // ], 81 | // "title": "get_forecastArguments", 82 | // "type": "object" 83 | // } 84 | // } 85 | // ] 86 | // } 87 | // } 88 | type Tool struct { 89 | Name string `json:"name"` 90 | Description string `json:"description,omitempty"` 91 | InputSchema ToolSchema `json:"inputSchema"` 92 | } 93 | 94 | type ToolSchema struct { 95 | Type string `json:"type"` 96 | Properties M `json:"properties"` 97 | Required []string `json:"required,omitempty"` 98 | } 99 | 100 | // { 101 | // "method": "tools/call", 102 | // "params": { 103 | // "name": "chatlog", 104 | // "arguments": { 105 | // "start": "2006-11-12", 106 | // "end": "2020-11-20", 107 | // "limit": "50", 108 | // "offset": "6" 109 | // }, 110 | // "_meta": { 111 | // "progressToken": 1 112 | // } 113 | // }, 114 | // "jsonrpc": "2.0", 115 | // "id": 3 116 | // } 117 | type ToolsCallRequest struct { 118 | Name string `json:"name"` 119 | Arguments M `json:"arguments"` 120 | } 121 | 122 | // { 123 | // "jsonrpc": "2.0", 124 | // "id": 2, 125 | // "result": { 126 | // "content": [ 127 | // { 128 | // "type": "text", 129 | // "text": "\nEvent: Winter Storm Warning\n" 130 | // } 131 | // ], 132 | // "isError": false 133 | // } 134 | // } 135 | type ToolsCallResponse struct { 136 | Content []Content `json:"content"` 137 | IsError bool `json:"isError"` 138 | } 139 | 140 | type Content struct { 141 | Type string `json:"type"` 142 | Text string `json:"text"` 143 | } 144 | -------------------------------------------------------------------------------- /internal/model/chatroom.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "github.com/sjzar/chatlog/internal/model/wxproto" 5 | 6 | "google.golang.org/protobuf/proto" 7 | ) 8 | 9 | type ChatRoom struct { 10 | Name string `json:"name"` 11 | Owner string `json:"owner"` 12 | Users []ChatRoomUser `json:"users"` 13 | 14 | // Extra From Contact 15 | Remark string `json:"remark"` 16 | NickName string `json:"nickName"` 17 | 18 | User2DisplayName map[string]string `json:"-"` 19 | } 20 | 21 | type ChatRoomUser struct { 22 | UserName string `json:"userName"` 23 | DisplayName string `json:"displayName"` 24 | } 25 | 26 | // CREATE TABLE ChatRoom( 27 | // ChatRoomName TEXT PRIMARY KEY, 28 | // UserNameList TEXT, 29 | // DisplayNameList TEXT, 30 | // ChatRoomFlag int Default 0, 31 | // Owner INTEGER DEFAULT 0, 32 | // IsShowName INTEGER DEFAULT 0, 33 | // SelfDisplayName TEXT, 34 | // Reserved1 INTEGER DEFAULT 0, 35 | // Reserved2 TEXT, 36 | // Reserved3 INTEGER DEFAULT 0, 37 | // Reserved4 TEXT, 38 | // Reserved5 INTEGER DEFAULT 0, 39 | // Reserved6 TEXT, 40 | // RoomData BLOB, 41 | // Reserved7 INTEGER DEFAULT 0, 42 | // Reserved8 TEXT 43 | // ) 44 | type ChatRoomV3 struct { 45 | ChatRoomName string `json:"ChatRoomName"` 46 | Reserved2 string `json:"Reserved2"` // Creator 47 | RoomData []byte `json:"RoomData"` 48 | 49 | // // 非关键信息,暂时忽略 50 | // UserNameList string `json:"UserNameList"` 51 | // DisplayNameList string `json:"DisplayNameList"` 52 | // ChatRoomFlag int `json:"ChatRoomFlag"` 53 | // Owner int `json:"Owner"` 54 | // IsShowName int `json:"IsShowName"` 55 | // SelfDisplayName string `json:"SelfDisplayName"` 56 | // Reserved1 int `json:"Reserved1"` 57 | // Reserved3 int `json:"Reserved3"` 58 | // Reserved4 string `json:"Reserved4"` 59 | // Reserved5 int `json:"Reserved5"` 60 | // Reserved6 string `json:"Reserved6"` 61 | // Reserved7 int `json:"Reserved7"` 62 | // Reserved8 string `json:"Reserved8"` 63 | } 64 | 65 | func (c *ChatRoomV3) Wrap() *ChatRoom { 66 | 67 | var users []ChatRoomUser 68 | if len(c.RoomData) != 0 { 69 | users = ParseRoomData(c.RoomData) 70 | } 71 | 72 | user2DisplayName := make(map[string]string, len(users)) 73 | for _, user := range users { 74 | if user.DisplayName != "" { 75 | user2DisplayName[user.UserName] = user.DisplayName 76 | } 77 | } 78 | 79 | return &ChatRoom{ 80 | Name: c.ChatRoomName, 81 | Owner: c.Reserved2, 82 | Users: users, 83 | User2DisplayName: user2DisplayName, 84 | } 85 | } 86 | 87 | func ParseRoomData(b []byte) (users []ChatRoomUser) { 88 | var pbMsg wxproto.RoomData 89 | if err := proto.Unmarshal(b, &pbMsg); err != nil { 90 | return 91 | } 92 | if pbMsg.Users == nil { 93 | return 94 | } 95 | 96 | users = make([]ChatRoomUser, 0, len(pbMsg.Users)) 97 | for _, user := range pbMsg.Users { 98 | u := ChatRoomUser{UserName: user.UserName} 99 | if user.DisplayName != nil { 100 | u.DisplayName = *user.DisplayName 101 | } 102 | users = append(users, u) 103 | } 104 | return users 105 | } 106 | 107 | func (c *ChatRoom) DisplayName() string { 108 | switch { 109 | case c.Remark != "": 110 | return c.Remark 111 | case c.NickName != "": 112 | return c.NickName 113 | } 114 | return "" 115 | } 116 | -------------------------------------------------------------------------------- /internal/model/chatroom_darwinv3.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "strings" 4 | 5 | // CREATE TABLE GroupContact( 6 | // m_nsUsrName TEXT PRIMARY KEY ASC, 7 | // m_uiConType INTEGER, 8 | // nickname TEXT, 9 | // m_nsFullPY TEXT, 10 | // m_nsShortPY TEXT, 11 | // m_nsRemark TEXT, 12 | // m_nsRemarkPYFull TEXT, 13 | // m_nsRemarkPYShort TEXT, 14 | // m_uiCertificationFlag INTEGER, 15 | // m_uiSex INTEGER, 16 | // m_uiType INTEGER, 17 | // m_nsImgStatus TEXT, 18 | // m_uiImgKey INTEGER, 19 | // m_nsHeadImgUrl TEXT, 20 | // m_nsHeadHDImgUrl TEXT, 21 | // m_nsHeadHDMd5 TEXT, 22 | // m_nsChatRoomMemList TEXT, 23 | // m_nsChatRoomAdminList TEXT, 24 | // m_uiChatRoomStatus INTEGER, 25 | // m_nsChatRoomDesc TEXT, 26 | // m_nsDraft TEXT, 27 | // m_nsBrandIconUrl TEXT, 28 | // m_nsGoogleContactName TEXT, 29 | // m_nsAliasName TEXT, 30 | // m_nsEncodeUserName TEXT, 31 | // m_uiChatRoomVersion INTEGER, 32 | // m_uiChatRoomMaxCount INTEGER, 33 | // m_uiChatRoomType INTEGER, 34 | // m_patSuffix TEXT, 35 | // richChatRoomDesc TEXT, 36 | // _packed_WCContactData BLOB, 37 | // openIMInfo BLOB 38 | // ) 39 | type ChatRoomDarwinV3 struct { 40 | M_nsUsrName string `json:"m_nsUsrName"` 41 | Nickname string `json:"nickname"` 42 | M_nsRemark string `json:"m_nsRemark"` 43 | M_nsChatRoomMemList string `json:"m_nsChatRoomMemList"` 44 | M_nsChatRoomAdminList string `json:"m_nsChatRoomAdminList"` 45 | 46 | // M_uiConType int `json:"m_uiConType"` 47 | // M_nsFullPY string `json:"m_nsFullPY"` 48 | // M_nsShortPY string `json:"m_nsShortPY"` 49 | // M_nsRemarkPYFull string `json:"m_nsRemarkPYFull"` 50 | // M_nsRemarkPYShort string `json:"m_nsRemarkPYShort"` 51 | // M_uiCertificationFlag int `json:"m_uiCertificationFlag"` 52 | // M_uiSex int `json:"m_uiSex"` 53 | // M_uiType int `json:"m_uiType"` 54 | // M_nsImgStatus string `json:"m_nsImgStatus"` 55 | // M_uiImgKey int `json:"m_uiImgKey"` 56 | // M_nsHeadImgUrl string `json:"m_nsHeadImgUrl"` 57 | // M_nsHeadHDImgUrl string `json:"m_nsHeadHDImgUrl"` 58 | // M_nsHeadHDMd5 string `json:"m_nsHeadHDMd5"` 59 | // M_uiChatRoomStatus int `json:"m_uiChatRoomStatus"` 60 | // M_nsChatRoomDesc string `json:"m_nsChatRoomDesc"` 61 | // M_nsDraft string `json:"m_nsDraft"` 62 | // M_nsBrandIconUrl string `json:"m_nsBrandIconUrl"` 63 | // M_nsGoogleContactName string `json:"m_nsGoogleContactName"` 64 | // M_nsAliasName string `json:"m_nsAliasName"` 65 | // M_nsEncodeUserName string `json:"m_nsEncodeUserName"` 66 | // M_uiChatRoomVersion int `json:"m_uiChatRoomVersion"` 67 | // M_uiChatRoomMaxCount int `json:"m_uiChatRoomMaxCount"` 68 | // M_uiChatRoomType int `json:"m_uiChatRoomType"` 69 | // M_patSuffix string `json:"m_patSuffix"` 70 | // RichChatRoomDesc string `json:"richChatRoomDesc"` 71 | // Packed_WCContactData []byte `json:"_packed_WCContactData"` 72 | // OpenIMInfo []byte `json:"openIMInfo"` 73 | } 74 | 75 | func (c *ChatRoomDarwinV3) Wrap(user2DisplayName map[string]string) *ChatRoom { 76 | 77 | split := strings.Split(c.M_nsChatRoomMemList, ";") 78 | users := make([]ChatRoomUser, 0, len(split)) 79 | _user2DisplayName := make(map[string]string) 80 | for _, v := range split { 81 | users = append(users, ChatRoomUser{ 82 | UserName: v, 83 | }) 84 | if name, ok := user2DisplayName[v]; ok { 85 | _user2DisplayName[v] = name 86 | } 87 | } 88 | 89 | return &ChatRoom{ 90 | Name: c.M_nsUsrName, 91 | Owner: c.M_nsChatRoomAdminList, 92 | Remark: c.M_nsRemark, 93 | NickName: c.Nickname, 94 | Users: users, 95 | User2DisplayName: _user2DisplayName, 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /internal/model/chatroom_v4.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // CREATE TABLE chat_room( 4 | // id INTEGER PRIMARY KEY, 5 | // username TEXT, 6 | // owner TEXT, 7 | // ext_buffer BLOB 8 | // ) 9 | type ChatRoomV4 struct { 10 | ID int `json:"id"` 11 | UserName string `json:"username"` 12 | Owner string `json:"owner"` 13 | ExtBuffer []byte `json:"ext_buffer"` 14 | } 15 | 16 | func (c *ChatRoomV4) Wrap() *ChatRoom { 17 | 18 | var users []ChatRoomUser 19 | if len(c.ExtBuffer) != 0 { 20 | users = ParseRoomData(c.ExtBuffer) 21 | } 22 | 23 | user2DisplayName := make(map[string]string, len(users)) 24 | for _, user := range users { 25 | if user.DisplayName != "" { 26 | user2DisplayName[user.UserName] = user.DisplayName 27 | } 28 | } 29 | 30 | return &ChatRoom{ 31 | Name: c.UserName, 32 | Owner: c.Owner, 33 | Users: users, 34 | User2DisplayName: user2DisplayName, 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /internal/model/contact.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Contact struct { 4 | UserName string `json:"userName"` 5 | Alias string `json:"alias"` 6 | Remark string `json:"remark"` 7 | NickName string `json:"nickName"` 8 | IsFriend bool `json:"isFriend"` 9 | } 10 | 11 | // CREATE TABLE Contact( 12 | // UserName TEXT PRIMARY KEY , 13 | // Alias TEXT, 14 | // EncryptUserName TEXT, 15 | // DelFlag INTEGER DEFAULT 0, 16 | // Type INTEGER DEFAULT 0, 17 | // VerifyFlag INTEGER DEFAULT 0, 18 | // Reserved1 INTEGER DEFAULT 0, 19 | // Reserved2 INTEGER DEFAULT 0, 20 | // Reserved3 TEXT, 21 | // Reserved4 TEXT, 22 | // Remark TEXT, 23 | // NickName TEXT, 24 | // LabelIDList TEXT, 25 | // DomainList TEXT, 26 | // ChatRoomType int, 27 | // PYInitial TEXT, 28 | // QuanPin TEXT, 29 | // RemarkPYInitial TEXT, 30 | // RemarkQuanPin TEXT, 31 | // BigHeadImgUrl TEXT, 32 | // SmallHeadImgUrl TEXT, 33 | // HeadImgMd5 TEXT, 34 | // ChatRoomNotify INTEGER DEFAULT 0, 35 | // Reserved5 INTEGER DEFAULT 0, 36 | // Reserved6 TEXT, 37 | // Reserved7 TEXT, 38 | // ExtraBuf BLOB, 39 | // Reserved8 INTEGER DEFAULT 0, 40 | // Reserved9 INTEGER DEFAULT 0, 41 | // Reserved10 TEXT, 42 | // Reserved11 TEXT 43 | // ) 44 | type ContactV3 struct { 45 | UserName string `json:"UserName"` 46 | Alias string `json:"Alias"` 47 | Remark string `json:"Remark"` 48 | NickName string `json:"NickName"` 49 | Reserved1 int `json:"Reserved1"` // 1 自己好友或自己加入的群聊; 0 群聊成员(非好友) 50 | } 51 | 52 | func (c *ContactV3) Wrap() *Contact { 53 | return &Contact{ 54 | UserName: c.UserName, 55 | Alias: c.Alias, 56 | Remark: c.Remark, 57 | NickName: c.NickName, 58 | IsFriend: c.Reserved1 == 1, 59 | } 60 | } 61 | 62 | func (c *Contact) DisplayName() string { 63 | switch { 64 | case c.Remark != "": 65 | return c.Remark 66 | case c.NickName != "": 67 | return c.NickName 68 | } 69 | return "" 70 | } 71 | -------------------------------------------------------------------------------- /internal/model/contact_darwinv3.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // CREATE TABLE WCContact( 4 | // m_nsUsrName TEXT PRIMARY KEY ASC, 5 | // m_uiConType INTEGER, 6 | // nickname TEXT, 7 | // m_nsFullPY TEXT, 8 | // m_nsShortPY TEXT, 9 | // m_nsRemark TEXT, 10 | // m_nsRemarkPYFull TEXT, 11 | // m_nsRemarkPYShort TEXT, 12 | // m_uiCertificationFlag INTEGER, 13 | // m_uiSex INTEGER, 14 | // m_uiType INTEGER, 15 | // m_nsImgStatus TEXT, 16 | // m_uiImgKey INTEGER, 17 | // m_nsHeadImgUrl TEXT, 18 | // m_nsHeadHDImgUrl TEXT, 19 | // m_nsHeadHDMd5 TEXT, 20 | // m_nsChatRoomMemList TEXT, 21 | // m_nsChatRoomAdminList TEXT, 22 | // m_uiChatRoomStatus INTEGER, 23 | // m_nsChatRoomDesc TEXT, 24 | // m_nsDraft TEXT, 25 | // m_nsBrandIconUrl TEXT, 26 | // m_nsGoogleContactName TEXT, 27 | // m_nsAliasName TEXT, 28 | // m_nsEncodeUserName TEXT, 29 | // m_uiChatRoomVersion INTEGER, 30 | // m_uiChatRoomMaxCount INTEGER, 31 | // m_uiChatRoomType INTEGER, 32 | // m_patSuffix TEXT, 33 | // richChatRoomDesc TEXT, 34 | // _packed_WCContactData BLOB, 35 | // openIMInfo BLOB 36 | // ) 37 | type ContactDarwinV3 struct { 38 | M_nsUsrName string `json:"m_nsUsrName"` 39 | Nickname string `json:"nickname"` 40 | M_nsRemark string `json:"m_nsRemark"` 41 | M_uiSex int `json:"m_uiSex"` 42 | M_nsAliasName string `json:"m_nsAliasName"` 43 | } 44 | 45 | func (c *ContactDarwinV3) Wrap() *Contact { 46 | return &Contact{ 47 | UserName: c.M_nsUsrName, 48 | Alias: c.M_nsAliasName, 49 | Remark: c.M_nsRemark, 50 | NickName: c.Nickname, 51 | IsFriend: true, 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /internal/model/contact_v4.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | // CREATE TABLE contact( 4 | // id INTEGER PRIMARY KEY, 5 | // username TEXT, 6 | // local_type INTEGER, 7 | // alias TEXT, 8 | // encrypt_username TEXT, 9 | // flag INTEGER, 10 | // delete_flag INTEGER, 11 | // verify_flag INTEGER, 12 | // remark TEXT, 13 | // remark_quan_pin TEXT, 14 | // remark_pin_yin_initial TEXT, 15 | // nick_name TEXT, 16 | // pin_yin_initial TEXT, 17 | // quan_pin TEXT, 18 | // big_head_url TEXT, 19 | // small_head_url TEXT, 20 | // head_img_md5 TEXT, 21 | // chat_room_notify INTEGER, 22 | // is_in_chat_room INTEGER, 23 | // description TEXT, 24 | // extra_buffer BLOB, 25 | // chat_room_type INTEGER 26 | // ) 27 | type ContactV4 struct { 28 | UserName string `json:"username"` 29 | Alias string `json:"alias"` 30 | Remark string `json:"remark"` 31 | NickName string `json:"nick_name"` 32 | LocalType int `json:"local_type"` // 2 群聊; 3 群聊成员(非好友); 5,6 企业微信; 33 | } 34 | 35 | func (c *ContactV4) Wrap() *Contact { 36 | return &Contact{ 37 | UserName: c.UserName, 38 | Alias: c.Alias, 39 | Remark: c.Remark, 40 | NickName: c.NickName, 41 | IsFriend: c.LocalType != 3, 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /internal/model/media.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "path/filepath" 5 | ) 6 | 7 | type Media struct { 8 | Type string `json:"type"` // 媒体类型:image, video, voice, file 9 | Key string `json:"key"` // MD5 10 | Path string `json:"path"` 11 | Name string `json:"name"` 12 | Size int64 `json:"size"` 13 | Data []byte `json:"data"` // for voice 14 | ModifyTime int64 `json:"modifyTime"` 15 | } 16 | 17 | type MediaV3 struct { 18 | Type string `json:"type"` 19 | Key string `json:"key"` 20 | Dir1 string `json:"dir1"` 21 | Dir2 string `json:"dir2"` 22 | Name string `json:"name"` 23 | ModifyTime int64 `json:"modifyTime"` 24 | } 25 | 26 | func (m *MediaV3) Wrap() *Media { 27 | 28 | var path string 29 | switch m.Type { 30 | case "image": 31 | path = filepath.Join("FileStorage", "MsgAttach", m.Dir1, "Image", m.Dir2, m.Name) 32 | case "video": 33 | path = filepath.Join("FileStorage", "Video", m.Dir2, m.Name) 34 | case "file": 35 | path = filepath.Join("FileStorage", "File", m.Dir2, m.Name) 36 | } 37 | 38 | return &Media{ 39 | Type: m.Type, 40 | Key: m.Key, 41 | ModifyTime: m.ModifyTime, 42 | Path: path, 43 | Name: m.Name, 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /internal/model/media_darwinv3.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "path/filepath" 4 | 5 | // CREATE TABLE HlinkMediaRecord( 6 | // mediaMd5 TEXT, 7 | // mediaSize INTEGER, 8 | // inodeNumber INTEGER, 9 | // modifyTime INTEGER , 10 | // CONSTRAINT _Md5_Size UNIQUE (mediaMd5,mediaSize) 11 | // ) 12 | // CREATE TABLE HlinkMediaDetail( 13 | // localId INTEGER PRIMARY KEY AUTOINCREMENT, 14 | // inodeNumber INTEGER, 15 | // relativePath TEXT, 16 | // fileName TEXT 17 | // ) 18 | type MediaDarwinV3 struct { 19 | MediaMd5 string `json:"mediaMd5"` 20 | MediaSize int64 `json:"mediaSize"` 21 | InodeNumber int64 `json:"inodeNumber"` 22 | ModifyTime int64 `json:"modifyTime"` 23 | RelativePath string `json:"relativePath"` 24 | FileName string `json:"fileName"` 25 | } 26 | 27 | func (m *MediaDarwinV3) Wrap() *Media { 28 | 29 | path := filepath.Join("Message/MessageTemp", m.RelativePath, m.FileName) 30 | name := filepath.Base(path) 31 | 32 | return &Media{ 33 | Type: "", 34 | Key: m.MediaMd5, 35 | Size: m.MediaSize, 36 | ModifyTime: m.ModifyTime, 37 | Path: path, 38 | Name: name, 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /internal/model/media_v4.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "path/filepath" 4 | 5 | type MediaV4 struct { 6 | Type string `json:"type"` 7 | Key string `json:"key"` 8 | Dir1 string `json:"dir1"` 9 | Dir2 string `json:"dir2"` 10 | Name string `json:"name"` 11 | Size int64 `json:"size"` 12 | ModifyTime int64 `json:"modifyTime"` 13 | } 14 | 15 | func (m *MediaV4) Wrap() *Media { 16 | 17 | var path string 18 | switch m.Type { 19 | case "image": 20 | path = filepath.Join("msg", "attach", m.Dir1, m.Dir2, "Img", m.Name) 21 | case "video": 22 | path = filepath.Join("msg", "video", m.Dir1, m.Name) 23 | case "file": 24 | path = filepath.Join("msg", "file", m.Dir1, m.Name) 25 | } 26 | 27 | return &Media{ 28 | Type: m.Type, 29 | Key: m.Key, 30 | Path: path, 31 | Name: m.Name, 32 | Size: m.Size, 33 | ModifyTime: m.ModifyTime, 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /internal/model/message_darwinv3.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | // CREATE TABLE Chat_md5(talker)( 9 | // mesLocalID INTEGER PRIMARY KEY AUTOINCREMENT, 10 | // mesSvrID INTEGER,msgCreateTime INTEGER, 11 | // msgContent TEXT,msgStatus INTEGER, 12 | // msgImgStatus INTEGER, 13 | // messageType INTEGER, 14 | // mesDes INTEGER, 15 | // msgSource TEXT, 16 | // IntRes1 INTEGER, 17 | // IntRes2 INTEGER, 18 | // StrRes1 TEXT, 19 | // StrRes2 TEXT, 20 | // msgVoiceText TEXT, 21 | // msgSeq INTEGER, 22 | // CompressContent BLOB, 23 | // ConBlob BLOB 24 | // ) 25 | type MessageDarwinV3 struct { 26 | MsgCreateTime int64 `json:"msgCreateTime"` 27 | MsgContent string `json:"msgContent"` 28 | MessageType int64 `json:"messageType"` 29 | MesDes int `json:"mesDes"` // 0: 发送, 1: 接收 30 | } 31 | 32 | func (m *MessageDarwinV3) Wrap(talker string) *Message { 33 | 34 | _m := &Message{ 35 | Time: time.Unix(m.MsgCreateTime, 0), 36 | Type: m.MessageType, 37 | Talker: talker, 38 | IsChatRoom: strings.HasSuffix(talker, "@chatroom"), 39 | IsSelf: m.MesDes == 0, 40 | Version: WeChatDarwinV3, 41 | } 42 | 43 | content := m.MsgContent 44 | if _m.IsChatRoom { 45 | split := strings.SplitN(content, ":\n", 2) 46 | if len(split) == 2 { 47 | _m.Sender = split[0] 48 | content = split[1] 49 | } 50 | } else if !_m.IsSelf { 51 | _m.Sender = talker 52 | } 53 | 54 | _m.ParseMediaInfo(content) 55 | 56 | return _m 57 | } 58 | -------------------------------------------------------------------------------- /internal/model/message_v3.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "fmt" 5 | "path/filepath" 6 | "strings" 7 | "time" 8 | 9 | "github.com/sjzar/chatlog/internal/model/wxproto" 10 | "github.com/sjzar/chatlog/pkg/util/lz4" 11 | "google.golang.org/protobuf/proto" 12 | ) 13 | 14 | // CREATE TABLE MSG ( 15 | // localId INTEGER PRIMARY KEY AUTOINCREMENT, 16 | // TalkerId INT DEFAULT 0, 17 | // MsgSvrID INT, 18 | // Type INT, 19 | // SubType INT, 20 | // IsSender INT, 21 | // CreateTime INT, 22 | // Sequence INT DEFAULT 0, 23 | // StatusEx INT DEFAULT 0, 24 | // FlagEx INT, 25 | // Status INT, 26 | // MsgServerSeq INT, 27 | // MsgSequence INT, 28 | // StrTalker TEXT, 29 | // StrContent TEXT, 30 | // DisplayContent TEXT, 31 | // Reserved0 INT DEFAULT 0, 32 | // Reserved1 INT DEFAULT 0, 33 | // Reserved2 INT DEFAULT 0, 34 | // Reserved3 INT DEFAULT 0, 35 | // Reserved4 TEXT, 36 | // Reserved5 TEXT, 37 | // Reserved6 TEXT, 38 | // CompressContent BLOB, 39 | // BytesExtra BLOB, 40 | // BytesTrans BLOB 41 | // ) 42 | type MessageV3 struct { 43 | MsgSvrID int64 `json:"MsgSvrID"` // 消息 ID 44 | Sequence int64 `json:"Sequence"` // 消息序号,10位时间戳 + 3位序号 45 | CreateTime int64 `json:"CreateTime"` // 消息创建时间,10位时间戳 46 | StrTalker string `json:"StrTalker"` // 聊天对象,微信 ID or 群 ID 47 | IsSender int `json:"IsSender"` // 是否为发送消息,0 接收消息,1 发送消息 48 | Type int64 `json:"Type"` // 消息类型 49 | SubType int `json:"SubType"` // 消息子类型 50 | StrContent string `json:"StrContent"` // 消息内容,文字聊天内容 或 XML 51 | CompressContent []byte `json:"CompressContent"` // 非文字聊天内容,如图片、语音、视频等 52 | BytesExtra []byte `json:"BytesExtra"` // protobuf 额外数据,记录群聊发送人等信息 53 | } 54 | 55 | func (m *MessageV3) Wrap() *Message { 56 | 57 | _m := &Message{ 58 | Seq: m.Sequence, 59 | Time: time.Unix(m.CreateTime, 0), 60 | Talker: m.StrTalker, 61 | IsChatRoom: strings.HasSuffix(m.StrTalker, "@chatroom"), 62 | IsSelf: m.IsSender == 1, 63 | Type: m.Type, 64 | SubType: int64(m.SubType), 65 | Content: m.StrContent, 66 | Version: WeChatV3, 67 | } 68 | 69 | if !_m.IsChatRoom && !_m.IsSelf { 70 | _m.Sender = m.StrTalker 71 | } 72 | 73 | if _m.Type == 49 { 74 | b, err := lz4.Decompress(m.CompressContent) 75 | if err == nil { 76 | _m.Content = string(b) 77 | } 78 | } 79 | 80 | _m.ParseMediaInfo(_m.Content) 81 | 82 | // 语音消息 83 | if _m.Type == 34 { 84 | _m.Contents["voice"] = fmt.Sprint(m.MsgSvrID) 85 | } 86 | 87 | if len(m.BytesExtra) != 0 { 88 | if bytesExtra := ParseBytesExtra(m.BytesExtra); bytesExtra != nil { 89 | if _m.IsChatRoom { 90 | _m.Sender = bytesExtra[1] 91 | } 92 | // FIXME xml 中的 md5 数据无法匹配到 hardlink 记录,所以直接用 proto 数据 93 | if _m.Type == 43 { 94 | path := bytesExtra[4] 95 | parts := strings.Split(filepath.ToSlash(path), "/") 96 | if len(parts) > 1 { 97 | path = strings.Join(parts[1:], "/") 98 | } 99 | _m.Contents["videofile"] = path 100 | } 101 | } 102 | } 103 | 104 | return _m 105 | } 106 | 107 | // ParseBytesExtra 解析额外数据 108 | // 按需解析 109 | func ParseBytesExtra(b []byte) map[int]string { 110 | var pbMsg wxproto.BytesExtra 111 | if err := proto.Unmarshal(b, &pbMsg); err != nil { 112 | return nil 113 | } 114 | if pbMsg.Items == nil { 115 | return nil 116 | } 117 | 118 | ret := make(map[int]string, len(pbMsg.Items)) 119 | for _, item := range pbMsg.Items { 120 | ret[int(item.Type)] = item.Value 121 | } 122 | 123 | return ret 124 | } 125 | -------------------------------------------------------------------------------- /internal/model/message_v4.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "bytes" 5 | "crypto/md5" 6 | "encoding/hex" 7 | "fmt" 8 | "path/filepath" 9 | "strings" 10 | "time" 11 | 12 | "github.com/sjzar/chatlog/internal/model/wxproto" 13 | "github.com/sjzar/chatlog/pkg/util/zstd" 14 | "google.golang.org/protobuf/proto" 15 | ) 16 | 17 | // CREATE TABLE Msg_md5(talker)( 18 | // local_id INTEGER PRIMARY KEY AUTOINCREMENT, 19 | // server_id INTEGER, 20 | // local_type INTEGER, 21 | // sort_seq INTEGER, 22 | // real_sender_id INTEGER, 23 | // create_time INTEGER, 24 | // status INTEGER, 25 | // upload_status INTEGER, 26 | // download_status INTEGER, 27 | // server_seq INTEGER, 28 | // origin_source INTEGER, 29 | // source TEXT, 30 | // message_content TEXT, 31 | // compress_content TEXT, 32 | // packed_info_data BLOB, 33 | // WCDB_CT_message_content INTEGER DEFAULT NULL, 34 | // WCDB_CT_source INTEGER DEFAULT NULL 35 | // ) 36 | type MessageV4 struct { 37 | SortSeq int64 `json:"sort_seq"` // 消息序号,10位时间戳 + 3位序号 38 | ServerID int64 `json:"server_id"` // 消息 ID,用于关联 voice 39 | LocalType int64 `json:"local_type"` // 消息类型 40 | UserName string `json:"user_name"` // 发送人,通过 Join Name2Id 表获得 41 | CreateTime int64 `json:"create_time"` // 消息创建时间,10位时间戳 42 | MessageContent []byte `json:"message_content"` // 消息内容,文字聊天内容 或 zstd 压缩内容 43 | PackedInfoData []byte `json:"packed_info_data"` // 额外数据,类似 proto,格式与 v3 有差异 44 | Status int `json:"status"` // 消息状态,2 是已发送,4 是已接收,可以用于判断 IsSender(FIXME 不准, 需要判断 UserName) 45 | } 46 | 47 | func (m *MessageV4) Wrap(talker string) *Message { 48 | 49 | _m := &Message{ 50 | Seq: m.SortSeq, 51 | Time: time.Unix(m.CreateTime, 0), 52 | Talker: talker, 53 | IsChatRoom: strings.HasSuffix(talker, "@chatroom"), 54 | Sender: m.UserName, 55 | Type: m.LocalType, 56 | Contents: make(map[string]interface{}), 57 | Version: WeChatV4, 58 | } 59 | 60 | // FIXME 后续通过 UserName 判断是否是自己发送的消息,目前可能不准确 61 | _m.IsSelf = m.Status == 2 || (!_m.IsChatRoom && talker != m.UserName) 62 | 63 | content := "" 64 | if bytes.HasPrefix(m.MessageContent, []byte{0x28, 0xb5, 0x2f, 0xfd}) { 65 | if b, err := zstd.Decompress(m.MessageContent); err == nil { 66 | content = string(b) 67 | } 68 | } else { 69 | content = string(m.MessageContent) 70 | } 71 | 72 | if _m.IsChatRoom { 73 | split := strings.SplitN(content, ":\n", 2) 74 | if len(split) == 2 { 75 | _m.Sender = split[0] 76 | content = split[1] 77 | } 78 | } 79 | 80 | _m.ParseMediaInfo(content) 81 | 82 | // 语音消息 83 | if _m.Type == 34 { 84 | _m.Contents["voice"] = fmt.Sprint(m.ServerID) 85 | } 86 | 87 | if len(m.PackedInfoData) != 0 { 88 | if packedInfo := ParsePackedInfo(m.PackedInfoData); packedInfo != nil { 89 | // FIXME 尝试解决 v4 版本 xml 数据无法匹配到 hardlink 记录的问题 90 | if _m.Type == 3 && packedInfo.Image != nil { 91 | _talkerMd5Bytes := md5.Sum([]byte(talker)) 92 | talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:]) 93 | _m.Contents["imgfile"] = filepath.Join("msg", "attach", talkerMd5, _m.Time.Format("2006-01"), "Img", fmt.Sprintf("%s.dat", packedInfo.Image.Md5)) 94 | _m.Contents["thumb"] = filepath.Join("msg", "attach", talkerMd5, _m.Time.Format("2006-01"), "Img", fmt.Sprintf("%s_t.dat", packedInfo.Image.Md5)) 95 | } 96 | if _m.Type == 43 && packedInfo.Video != nil { 97 | _m.Contents["videofile"] = filepath.Join("msg", "video", _m.Time.Format("2006-01"), fmt.Sprintf("%s.mp4", packedInfo.Video.Md5)) 98 | _m.Contents["thumb"] = filepath.Join("msg", "video", _m.Time.Format("2006-01"), fmt.Sprintf("%s_thumb.jpg", packedInfo.Video.Md5)) 99 | } 100 | } 101 | } 102 | 103 | return _m 104 | } 105 | 106 | func ParsePackedInfo(b []byte) *wxproto.PackedInfo { 107 | var pbMsg wxproto.PackedInfo 108 | if err := proto.Unmarshal(b, &pbMsg); err != nil { 109 | return nil 110 | } 111 | return &pbMsg 112 | } 113 | -------------------------------------------------------------------------------- /internal/model/session.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import ( 4 | "strings" 5 | "time" 6 | ) 7 | 8 | type Session struct { 9 | UserName string `json:"userName"` 10 | NOrder int `json:"nOrder"` 11 | NickName string `json:"nickName"` 12 | Content string `json:"content"` 13 | NTime time.Time `json:"nTime"` 14 | } 15 | 16 | // CREATE TABLE Session( 17 | // strUsrName TEXT PRIMARY KEY, 18 | // nOrder INT DEFAULT 0, 19 | // nUnReadCount INTEGER DEFAULT 0, 20 | // parentRef TEXT, 21 | // Reserved0 INTEGER DEFAULT 0, 22 | // Reserved1 TEXT, 23 | // strNickName TEXT, 24 | // nStatus INTEGER, 25 | // nIsSend INTEGER, 26 | // strContent TEXT, 27 | // nMsgType INTEGER, 28 | // nMsgLocalID INTEGER, 29 | // nMsgStatus INTEGER, 30 | // nTime INTEGER, 31 | // editContent TEXT, 32 | // othersAtMe INT, 33 | // Reserved2 INTEGER DEFAULT 0, 34 | // Reserved3 TEXT, 35 | // Reserved4 INTEGER DEFAULT 0, 36 | // Reserved5 TEXT, 37 | // bytesXml BLOB 38 | // ) 39 | type SessionV3 struct { 40 | StrUsrName string `json:"strUsrName"` 41 | NOrder int `json:"nOrder"` 42 | StrNickName string `json:"strNickName"` 43 | StrContent string `json:"strContent"` 44 | NTime int64 `json:"nTime"` 45 | 46 | // NUnReadCount int `json:"nUnReadCount"` 47 | // ParentRef string `json:"parentRef"` 48 | // Reserved0 int `json:"Reserved0"` 49 | // Reserved1 string `json:"Reserved1"` 50 | // NStatus int `json:"nStatus"` 51 | // NIsSend int `json:"nIsSend"` 52 | // NMsgType int `json:"nMsgType"` 53 | // NMsgLocalID int `json:"nMsgLocalID"` 54 | // NMsgStatus int `json:"nMsgStatus"` 55 | // EditContent string `json:"editContent"` 56 | // OthersAtMe int `json:"othersAtMe"` 57 | // Reserved2 int `json:"Reserved2"` 58 | // Reserved3 string `json:"Reserved3"` 59 | // Reserved4 int `json:"Reserved4"` 60 | // Reserved5 string `json:"Reserved5"` 61 | // BytesXml string `json:"bytesXml"` 62 | } 63 | 64 | func (s *SessionV3) Wrap() *Session { 65 | return &Session{ 66 | UserName: s.StrUsrName, 67 | NOrder: s.NOrder, 68 | NickName: s.StrNickName, 69 | Content: s.StrContent, 70 | NTime: time.Unix(int64(s.NTime), 0), 71 | } 72 | } 73 | 74 | func (s *Session) PlainText(limit int) string { 75 | buf := strings.Builder{} 76 | buf.WriteString(s.NickName) 77 | buf.WriteString("(") 78 | buf.WriteString(s.UserName) 79 | buf.WriteString(") ") 80 | buf.WriteString(s.NTime.Format("2006-01-02 15:04:05")) 81 | buf.WriteString("\n") 82 | if limit > 0 { 83 | if len(s.Content) > limit { 84 | buf.WriteString(s.Content[:limit]) 85 | buf.WriteString(" <...>") 86 | } else { 87 | buf.WriteString(s.Content) 88 | } 89 | } 90 | buf.WriteString("\n") 91 | return buf.String() 92 | } 93 | -------------------------------------------------------------------------------- /internal/model/session_darwinv3.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | // CREATE TABLE SessionAbstract( 6 | // m_nsUserName TEXT PRIMARY KEY, 7 | // m_uUnReadCount INTEGER, 8 | // m_bShowUnReadAsRedDot INTEGER, 9 | // m_bMarkUnread INTEGER, 10 | // m_uLastTime INTEGER, 11 | // strRes1 TEXT, 12 | // strRes2 TEXT, 13 | // strRes3 TEXT, 14 | // intRes1 INTEGER, 15 | // intRes2 INTEGER, 16 | // intRes3 INTEGER, 17 | // _packed_MMSessionInfo BLOB 18 | // ) 19 | type SessionDarwinV3 struct { 20 | M_nsUserName string `json:"m_nsUserName"` 21 | M_uLastTime int `json:"m_uLastTime"` 22 | 23 | // M_uUnReadCount int `json:"m_uUnReadCount"` 24 | // M_bShowUnReadAsRedDot int `json:"m_bShowUnReadAsRedDot"` 25 | // M_bMarkUnread int `json:"m_bMarkUnread"` 26 | // StrRes1 string `json:"strRes1"` 27 | // StrRes2 string `json:"strRes2"` 28 | // StrRes3 string `json:"strRes3"` 29 | // IntRes1 int `json:"intRes1"` 30 | // IntRes2 int `json:"intRes2"` 31 | // IntRes3 int `json:"intRes3"` 32 | // PackedMMSessionInfo string `json:"_packed_MMSessionInfo"` // TODO: decode 33 | } 34 | 35 | func (s *SessionDarwinV3) Wrap() *Session { 36 | return &Session{ 37 | UserName: s.M_nsUserName, 38 | NOrder: s.M_uLastTime, 39 | NTime: time.Unix(int64(s.M_uLastTime), 0), 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /internal/model/session_v4.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | import "time" 4 | 5 | // 注意,v4 session 是独立数据库文件 6 | // CREATE TABLE SessionTable( 7 | // username TEXT PRIMARY KEY, 8 | // type INTEGER, 9 | // unread_count INTEGER, 10 | // unread_first_msg_srv_id INTEGER, 11 | // is_hidden INTEGER, 12 | // summary TEXT, 13 | // draft TEXT, 14 | // status INTEGER, 15 | // last_timestamp INTEGER, 16 | // sort_timestamp INTEGER, 17 | // last_clear_unread_timestamp INTEGER, 18 | // last_msg_locald_id INTEGER, 19 | // last_msg_type INTEGER, 20 | // last_msg_sub_type INTEGER, 21 | // last_msg_sender TEXT, 22 | // last_sender_display_name TEXT, 23 | // last_msg_ext_type INTEGER 24 | // ) 25 | type SessionV4 struct { 26 | Username string `json:"username"` 27 | Summary string `json:"summary"` 28 | LastTimestamp int `json:"last_timestamp"` 29 | LastMsgSender string `json:"last_msg_sender"` 30 | LastSenderDisplayName string `json:"last_sender_display_name"` 31 | 32 | // Type int `json:"type"` 33 | // UnreadCount int `json:"unread_count"` 34 | // UnreadFirstMsgSrvID int `json:"unread_first_msg_srv_id"` 35 | // IsHidden int `json:"is_hidden"` 36 | // Draft string `json:"draft"` 37 | // Status int `json:"status"` 38 | // SortTimestamp int `json:"sort_timestamp"` 39 | // LastClearUnreadTimestamp int `json:"last_clear_unread_timestamp"` 40 | // LastMsgLocaldID int `json:"last_msg_locald_id"` 41 | // LastMsgType int `json:"last_msg_type"` 42 | // LastMsgSubType int `json:"last_msg_sub_type"` 43 | // LastMsgExtType int `json:"last_msg_ext_type"` 44 | } 45 | 46 | func (s *SessionV4) Wrap() *Session { 47 | return &Session{ 48 | UserName: s.Username, 49 | NOrder: s.LastTimestamp, 50 | NickName: s.LastSenderDisplayName, 51 | Content: s.Summary, 52 | NTime: time.Unix(int64(s.LastTimestamp), 0), 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /internal/model/wxproto/bytesextra.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package app.protobuf; 3 | option go_package=".;wxproto"; 4 | 5 | message BytesExtraHeader { 6 | int32 field1 = 1; 7 | int32 field2 = 2; 8 | } 9 | 10 | message BytesExtraItem { 11 | int32 type = 1; 12 | string value = 2; 13 | } 14 | 15 | message BytesExtra { 16 | BytesExtraHeader header = 1; 17 | repeated BytesExtraItem items = 3; 18 | } -------------------------------------------------------------------------------- /internal/model/wxproto/packedinfo.proto: -------------------------------------------------------------------------------- 1 | syntax = "proto3"; 2 | package app.protobuf; 3 | option go_package=".;wxproto"; 4 | 5 | message PackedInfo { 6 | uint32 type = 1; // 始终为 106 (0x6a) 7 | uint32 version = 2; // 始终为 14 (0xe) 8 | ImageHash image = 3; // 图片哈希 9 | VideoHash video = 4; // 视频哈希 10 | } 11 | 12 | 13 | message ImageHash { 14 | string md5 = 4; // 32 字符的 MD5 哈希 15 | } 16 | 17 | message VideoHash { 18 | string md5 = 8; // 32 字符的 MD5 哈希 19 | } -------------------------------------------------------------------------------- /internal/model/wxproto/roomdata.proto: -------------------------------------------------------------------------------- 1 | // v3 & v4 通用,可能会有部分字段差异 2 | syntax = "proto3"; 3 | package app.protobuf; 4 | option go_package=".;wxproto"; 5 | 6 | message RoomData { 7 | repeated RoomDataUser users = 1; 8 | optional int32 roomCap = 5; // 只在第一份数据中出现,值为500 9 | } 10 | 11 | message RoomDataUser { 12 | string userName = 1; // 用户ID或名称 13 | optional string displayName = 2; // 显示名称,可能是UTF-8编码的中文,部分记录可能为空 14 | int32 status = 3; // 状态码,值范围0-9 15 | optional string inviter = 4; // 邀请人 16 | } 17 | -------------------------------------------------------------------------------- /internal/ui/footer/footer.go: -------------------------------------------------------------------------------- 1 | package footer 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sjzar/chatlog/internal/ui/style" 7 | "github.com/sjzar/chatlog/pkg/version" 8 | 9 | "github.com/rivo/tview" 10 | ) 11 | 12 | const ( 13 | Title = "footer" 14 | ) 15 | 16 | type Footer struct { 17 | *tview.Flex 18 | title string 19 | copyRight *tview.TextView 20 | help *tview.TextView 21 | } 22 | 23 | func New() *Footer { 24 | footer := &Footer{ 25 | Flex: tview.NewFlex(), 26 | title: Title, 27 | copyRight: tview.NewTextView(), 28 | help: tview.NewTextView(), 29 | } 30 | 31 | footer.copyRight. 32 | SetDynamicColors(true). 33 | SetWrap(true). 34 | SetTextAlign(tview.AlignLeft) 35 | footer.copyRight. 36 | SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) 37 | footer.copyRight.SetText(fmt.Sprintf("[%s::b]%s[-:-:-]", style.GetColorHex(style.PageHeaderFgColor), fmt.Sprintf(" @ Sarv's Chatlog %s", version.Version))) 38 | 39 | footer.help. 40 | SetDynamicColors(true). 41 | SetWrap(true). 42 | SetTextAlign(tview.AlignRight) 43 | footer.help. 44 | SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor) 45 | 46 | fmt.Fprintf(footer.help, 47 | "[%s::b]↑/↓[%s::b]: 导航 [%s::b]←/→[%s::b]: 切换标签 [%s::b]Enter[%s::b]: 选择 [%s::b]ESC[%s::b]: 返回 [%s::b]Ctrl+C[%s::b]: 退出", 48 | style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor), 49 | style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor), 50 | style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor), 51 | style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor), 52 | style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor), 53 | ) 54 | 55 | footer. 56 | AddItem(footer.copyRight, 0, 1, false). 57 | AddItem(footer.help, 0, 1, false) 58 | 59 | return footer 60 | } 61 | 62 | func (f *Footer) SetCopyRight(text string) { 63 | f.copyRight.SetText(text) 64 | } 65 | 66 | func (f *Footer) SetHelp(text string) { 67 | f.help.SetText(text) 68 | } 69 | -------------------------------------------------------------------------------- /internal/ui/help/help.go: -------------------------------------------------------------------------------- 1 | package help 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sjzar/chatlog/internal/ui/style" 7 | 8 | "github.com/rivo/tview" 9 | ) 10 | 11 | const ( 12 | Title = "help" 13 | ShowTitle = "帮助" 14 | Content = `[yellow]Chatlog 使用指南[white] 15 | 16 | [green]基本操作:[white] 17 | • 使用 [yellow]←→[white] 键在主菜单和帮助页面之间切换 18 | • 使用 [yellow]↑↓[white] 键在菜单项之间移动 19 | • 按 [yellow]Enter[white] 选择菜单项 20 | • 按 [yellow]Esc[white] 返回上一级菜单 21 | • 按 [yellow]Ctrl+C[white] 退出程序 22 | 23 | [green]使用步骤:[white] 24 | 25 | [yellow]1. 下载并安装微信客户端[white] 26 | 27 | [yellow]2. 迁移手机微信聊天记录[white] 28 | 手机微信上操作 [yellow]我 - 设置 - 通用 - 聊天记录迁移与备份 - 迁移 - 迁移到电脑[white]。 29 | 这一步的目的是将手机中的聊天记录传输到电脑上。 30 | 可以放心操作,不会影响到手机上的聊天记录。 31 | 32 | [yellow]3. 解密数据[white] 33 | 重新打开 chatlog,选择"解密数据"菜单项,程序会使用获取的密钥解密微信数据库文件。 34 | 解密后的文件会保存到工作目录中(可在设置中修改)。 35 | 36 | [yellow]4. 启动 HTTP 服务[white] 37 | 选择"启动 HTTP 服务"菜单项,启动 HTTP 和 MCP 服务。 38 | 启动后可以通过浏览器访问 http://localhost:5030 查看聊天记录。 39 | 40 | [yellow]5. 设置选项[white] 41 | 选择"设置"菜单项,可以配置: 42 | • HTTP 服务端口 - 更改 HTTP 服务的监听端口 43 | • 工作目录 - 更改解密数据的存储位置 44 | 45 | [green]HTTP API 使用:[white] 46 | • 聊天记录: [yellow]GET http://localhost:5030/api/v1/chatlog?time=2023-01-01&talker=wxid_xxx[white] 47 | • 联系人列表: [yellow]GET http://localhost:5030/api/v1/contact[white] 48 | • 群聊列表: [yellow]GET http://localhost:5030/api/v1/chatroom[white] 49 | • 会话列表: [yellow]GET http://localhost:5030/api/v1/session[white] 50 | 51 | [green]MCP 集成:[white] 52 | Chatlog 支持 Model Context Protocol,可与支持 MCP 的 AI 助手集成。 53 | 通过 MCP,AI 助手可以直接查询您的聊天记录、联系人和群聊信息。 54 | 55 | [green]常见问题:[white] 56 | • 如果获取密钥失败,请确保微信程序正在运行 57 | • 如果解密失败,请检查密钥是否正确获取 58 | • 如果 HTTP 服务启动失败,请检查端口是否被占用 59 | • 数据目录和工作目录会自动保存,下次启动时自动加载 60 | 61 | [green]数据安全:[white] 62 | • 所有数据处理均在本地完成,不会上传到任何外部服务器 63 | • 请妥善保管解密后的数据,避免隐私泄露 64 | ` 65 | ) 66 | 67 | type Help struct { 68 | *tview.TextView 69 | title string 70 | } 71 | 72 | func New() *Help { 73 | help := &Help{ 74 | TextView: tview.NewTextView(), 75 | title: Title, 76 | } 77 | 78 | help.SetDynamicColors(true) 79 | help.SetRegions(true) 80 | help.SetWrap(true) 81 | help.SetTextAlign(tview.AlignLeft) 82 | help.SetBorder(true) 83 | help.SetBorderColor(style.BorderColor) 84 | help.SetTitle(ShowTitle) 85 | 86 | fmt.Fprint(help, Content) 87 | 88 | return help 89 | } 90 | -------------------------------------------------------------------------------- /internal/ui/menu/menu.go: -------------------------------------------------------------------------------- 1 | package menu 2 | 3 | import ( 4 | "fmt" 5 | "sort" 6 | 7 | "github.com/sjzar/chatlog/internal/ui/style" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | ) 12 | 13 | type Item struct { 14 | Index int 15 | Key string 16 | Name string 17 | Description string 18 | Hidden bool 19 | Selected func(i *Item) 20 | } 21 | 22 | type Menu struct { 23 | *tview.Box 24 | title string 25 | table *tview.Table 26 | items []*Item 27 | } 28 | 29 | func New(title string) *Menu { 30 | menu := &Menu{ 31 | Box: tview.NewBox(), 32 | title: title, 33 | items: make([]*Item, 0), 34 | table: tview.NewTable(), 35 | } 36 | 37 | menu.table.SetBorders(false) 38 | menu.table.SetSelectable(true, false) 39 | menu.table.SetTitle(fmt.Sprintf("[::b]%s", menu.title)) 40 | menu.table.SetBorderColor(style.BorderColor) 41 | menu.table.SetBackgroundColor(style.BgColor) 42 | menu.table.SetTitleColor(style.FgColor) 43 | menu.table.SetFixed(1, 0) 44 | menu.table.Select(1, 0).SetSelectedFunc(func(row, column int) { 45 | if row == 0 { 46 | return // 忽略表头 47 | } 48 | 49 | item, ok := menu.table.GetCell(row, 0).GetReference().(*Item) 50 | if ok { 51 | if item.Selected != nil { 52 | item.Selected(item) 53 | } 54 | } 55 | }) 56 | 57 | menu.setTableHeader() 58 | 59 | return menu 60 | } 61 | 62 | func (m *Menu) setTableHeader() { 63 | m.table.SetCell(0, 0, tview.NewTableCell(fmt.Sprintf("[black::b]%s", "命令")). 64 | SetExpansion(1). 65 | SetBackgroundColor(style.PageHeaderBgColor). 66 | SetTextColor(style.PageHeaderFgColor). 67 | SetAlign(tview.AlignLeft). 68 | SetSelectable(false)) 69 | 70 | m.table.SetCell(0, 1, tview.NewTableCell(fmt.Sprintf("[black::b]%s", "说明")). 71 | SetExpansion(2). 72 | SetBackgroundColor(style.PageHeaderBgColor). 73 | SetTextColor(style.PageHeaderFgColor). 74 | SetAlign(tview.AlignLeft). 75 | SetSelectable(false)) 76 | } 77 | 78 | func (m *Menu) AddItem(item *Item) { 79 | m.items = append(m.items, item) 80 | sort.Sort(SortItems(m.items)) 81 | m.refresh() 82 | } 83 | 84 | func (m *Menu) SetItems(items []*Item) { 85 | m.items = items 86 | m.refresh() 87 | } 88 | 89 | func (m *Menu) GetItems() []*Item { 90 | return m.items 91 | } 92 | 93 | func (m *Menu) refresh() { 94 | m.table.Clear() 95 | m.setTableHeader() 96 | 97 | row := 1 98 | for _, item := range m.items { 99 | if item.Hidden { 100 | continue 101 | } 102 | m.table.SetCell(row, 0, tview.NewTableCell(item.Name). 103 | SetTextColor(style.FgColor). 104 | SetBackgroundColor(style.BgColor). 105 | SetReference(item). 106 | SetAlign(tview.AlignLeft)) 107 | m.table.SetCell(row, 1, tview.NewTableCell(item.Description). 108 | SetTextColor(style.FgColor). 109 | SetBackgroundColor(style.BgColor). 110 | SetReference(item). 111 | SetAlign(tview.AlignLeft)) 112 | row++ 113 | } 114 | 115 | } 116 | 117 | func (m *Menu) Draw(screen tcell.Screen) { 118 | m.refresh() 119 | 120 | m.Box.DrawForSubclass(screen, m) 121 | m.Box.SetBorder(false) 122 | 123 | menuViewX, menuViewY, menuViewW, menuViewH := m.GetInnerRect() 124 | 125 | m.table.SetRect(menuViewX, menuViewY, menuViewW, menuViewH) 126 | m.table.SetBorder(true).SetBorderColor(style.BorderColor) 127 | 128 | m.table.Draw(screen) 129 | } 130 | 131 | func (m *Menu) Focus(delegate func(p tview.Primitive)) { 132 | delegate(m.table) 133 | } 134 | 135 | // HasFocus returns whether or not this primitive has focus 136 | func (m *Menu) HasFocus() bool { 137 | // Check if the active menu has focus 138 | return m.table.HasFocus() 139 | } 140 | 141 | func (m *Menu) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 142 | return m.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) { 143 | // 将事件传递给表格 144 | if handler := m.table.InputHandler(); handler != nil { 145 | handler(event, setFocus) 146 | } 147 | }) 148 | } 149 | 150 | type SortItems []*Item 151 | 152 | func (l SortItems) Len() int { 153 | return len(l) 154 | } 155 | 156 | func (l SortItems) Less(i, j int) bool { 157 | return l[i].Index < l[j].Index 158 | } 159 | 160 | func (l SortItems) Swap(i, j int) { 161 | l[i], l[j] = l[j], l[i] 162 | } 163 | -------------------------------------------------------------------------------- /internal/ui/style/style.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | // +build !windows 3 | 4 | package style 5 | 6 | import ( 7 | "fmt" 8 | 9 | "github.com/gdamore/tcell/v2" 10 | "github.com/rivo/tview" 11 | ) 12 | 13 | const ( 14 | // HeavyGreenCheckMark unicode. 15 | HeavyGreenCheckMark = "\u2705" 16 | // HeavyRedCrossMark unicode. 17 | HeavyRedCrossMark = "\u274C" 18 | // ProgressBar cell. 19 | ProgressBarCell = "▉" 20 | ) 21 | 22 | var ( 23 | // infobar. 24 | InfoBarItemFgColor = tcell.ColorSilver 25 | // main views. 26 | FgColor = tcell.ColorFloralWhite 27 | BgColor = tview.Styles.PrimitiveBackgroundColor 28 | BorderColor = tcell.NewRGBColor(135, 175, 146) //nolint:mnd 29 | HelpHeaderFgColor = tcell.NewRGBColor(135, 175, 146) //nolint:mnd 30 | MenuBgColor = tcell.ColorMediumSeaGreen 31 | PageHeaderBgColor = tcell.ColorMediumSeaGreen 32 | PageHeaderFgColor = tcell.ColorFloralWhite 33 | RunningStatusFgColor = tcell.NewRGBColor(95, 215, 0) //nolint:mnd 34 | PausedStatusFgColor = tcell.NewRGBColor(255, 175, 0) //nolint:mnd 35 | // dialogs. 36 | DialogBgColor = tcell.NewRGBColor(38, 38, 38) //nolint:mnd 37 | DialogBorderColor = tcell.ColorMediumSeaGreen 38 | DialogFgColor = tcell.ColorFloralWhite 39 | DialogSubBoxBorderColor = tcell.ColorDimGray 40 | ErrorDialogBgColor = tcell.NewRGBColor(215, 0, 0) //nolint:mnd 41 | ErrorDialogButtonBgColor = tcell.ColorDarkRed 42 | // terminal. 43 | TerminalFgColor = tcell.ColorFloralWhite 44 | TerminalBgColor = tcell.NewRGBColor(5, 5, 5) //nolint:mnd 45 | TerminalBorderColor = tcell.ColorDimGray 46 | // table header. 47 | TableHeaderBgColor = tcell.ColorMediumSeaGreen 48 | TableHeaderFgColor = tcell.ColorFloralWhite 49 | // progress bar. 50 | PrgBgColor = tcell.ColorDimGray 51 | PrgBarColor = tcell.ColorDarkOrange 52 | PrgBarEmptyColor = tcell.ColorWhite 53 | PrgBarOKColor = tcell.ColorGreen 54 | PrgBarWarnColor = tcell.ColorOrange 55 | PrgBarCritColor = tcell.ColorRed 56 | // dropdown. 57 | DropDownUnselected = tcell.StyleDefault.Background(tcell.ColorWhiteSmoke).Foreground(tcell.ColorBlack) 58 | DropDownSelected = tcell.StyleDefault.Background(tcell.ColorLightSlateGray).Foreground(tcell.ColorWhite) 59 | // other primitives. 60 | InputFieldBgColor = tcell.ColorGray 61 | ButtonBgColor = tcell.ColorMediumSeaGreen 62 | ) 63 | 64 | // GetColorName returns convert tcell color to its name. 65 | func GetColorName(color tcell.Color) string { 66 | for name, c := range tcell.ColorNames { 67 | if c == color { 68 | return name 69 | } 70 | } 71 | 72 | return "" 73 | } 74 | 75 | // GetColorHex returns convert tcell color to its hex useful for textview primitives. 76 | func GetColorHex(color tcell.Color) string { 77 | return fmt.Sprintf("#%x", color.Hex()) 78 | } 79 | -------------------------------------------------------------------------------- /internal/ui/style/style_windows.go: -------------------------------------------------------------------------------- 1 | //go:build windows 2 | 3 | package style 4 | 5 | import ( 6 | "github.com/gdamore/tcell/v2" 7 | "github.com/rivo/tview" 8 | ) 9 | 10 | const ( 11 | // HeavyGreenCheckMark unicode. 12 | HeavyGreenCheckMark = "[green::]\u25CF[-::]" 13 | // HeavyRedCrossMark unicode. 14 | HeavyRedCrossMark = "[red::]\u25CF[-::]" 15 | // ProgressBar cell. 16 | ProgressBarCell = "\u2593" 17 | ) 18 | 19 | var ( 20 | // infobar. 21 | InfoBarItemFgColor = tcell.ColorGray 22 | // main views. 23 | FgColor = tview.Styles.PrimaryTextColor 24 | BgColor = tview.Styles.PrimitiveBackgroundColor 25 | BorderColor = tcell.ColorSpringGreen 26 | MenuBgColor = tcell.ColorSpringGreen 27 | HelpHeaderFgColor = tcell.ColorSpringGreen 28 | PageHeaderBgColor = tcell.ColorSpringGreen 29 | PageHeaderFgColor = tview.Styles.PrimaryTextColor 30 | RunningStatusFgColor = tcell.ColorLime 31 | PausedStatusFgColor = tcell.ColorYellow 32 | 33 | // dialogs. 34 | DialogBgColor = tview.Styles.PrimitiveBackgroundColor 35 | DialogFgColor = tview.Styles.PrimaryTextColor 36 | DialogBorderColor = tcell.ColorSpringGreen 37 | DialogSubBoxBorderColor = tcell.ColorGray 38 | ErrorDialogBgColor = tcell.ColorRed 39 | ErrorDialogButtonBgColor = tcell.ColorSpringGreen 40 | // terminal. 41 | TerminalBgColor = tview.Styles.PrimitiveBackgroundColor 42 | TerminalFgColor = tview.Styles.PrimaryTextColor 43 | TerminalBorderColor = tview.Styles.PrimitiveBackgroundColor 44 | // table header. 45 | TableHeaderBgColor = tcell.ColorSpringGreen 46 | TableHeaderFgColor = tview.Styles.PrimaryTextColor 47 | // progress bar. 48 | PrgBgColor = tview.Styles.PrimaryTextColor 49 | PrgBarColor = tcell.ColorFuchsia 50 | PrgBarEmptyColor = tcell.ColorWhite 51 | PrgBarOKColor = tcell.ColorLime 52 | PrgBarWarnColor = tcell.ColorYellow 53 | PrgBarCritColor = tcell.ColorRed 54 | // dropdown. 55 | DropDownUnselected = tcell.StyleDefault.Background(tcell.ColorGray).Foreground(tcell.ColorWhite) 56 | DropDownSelected = tcell.StyleDefault.Background(tcell.ColorPurple).Foreground(tview.Styles.PrimaryTextColor) 57 | // other primitives. 58 | InputFieldBgColor = tcell.ColorGray 59 | ButtonBgColor = tcell.ColorSpringGreen 60 | ) 61 | 62 | // GetColorName returns convert tcell color to its name. 63 | func GetColorName(color tcell.Color) string { 64 | for name, c := range tcell.ColorNames { 65 | if c == color { 66 | return name 67 | } 68 | } 69 | return "" 70 | } 71 | 72 | // GetColorHex shall returns convert tcell color to its hex useful for textview primitives, 73 | // however, for windows nodes it will return color name. 74 | func GetColorHex(color tcell.Color) string { 75 | for name, c := range tcell.ColorNames { 76 | if c == color { 77 | return name 78 | } 79 | } 80 | return "" 81 | } 82 | -------------------------------------------------------------------------------- /internal/wechat/decrypt/common/common.go: -------------------------------------------------------------------------------- 1 | package common 2 | 3 | import ( 4 | "bytes" 5 | "crypto/aes" 6 | "crypto/cipher" 7 | "crypto/hmac" 8 | "encoding/binary" 9 | "fmt" 10 | "hash" 11 | "io" 12 | "os" 13 | 14 | "github.com/sjzar/chatlog/internal/errors" 15 | ) 16 | 17 | const ( 18 | KeySize = 32 19 | SaltSize = 16 20 | AESBlockSize = 16 21 | SQLiteHeader = "SQLite format 3\x00" 22 | IVSize = 16 23 | ) 24 | 25 | type DBFile struct { 26 | Path string 27 | Salt []byte 28 | TotalPages int64 29 | FirstPage []byte 30 | } 31 | 32 | func OpenDBFile(dbPath string, pageSize int) (*DBFile, error) { 33 | fp, err := os.Open(dbPath) 34 | if err != nil { 35 | return nil, errors.OpenFileFailed(dbPath, err) 36 | } 37 | defer fp.Close() 38 | 39 | fileInfo, err := fp.Stat() 40 | if err != nil { 41 | return nil, errors.StatFileFailed(dbPath, err) 42 | } 43 | 44 | fileSize := fileInfo.Size() 45 | totalPages := fileSize / int64(pageSize) 46 | if fileSize%int64(pageSize) > 0 { 47 | totalPages++ 48 | } 49 | 50 | buffer := make([]byte, pageSize) 51 | n, err := io.ReadFull(fp, buffer) 52 | if err != nil { 53 | return nil, errors.ReadFileFailed(dbPath, err) 54 | } 55 | if n != pageSize { 56 | return nil, errors.IncompleteRead(fmt.Errorf("read %d bytes, expected %d", n, pageSize)) 57 | } 58 | 59 | if bytes.Equal(buffer[:len(SQLiteHeader)-1], []byte(SQLiteHeader[:len(SQLiteHeader)-1])) { 60 | return nil, errors.ErrAlreadyDecrypted 61 | } 62 | 63 | return &DBFile{ 64 | Path: dbPath, 65 | Salt: buffer[:SaltSize], 66 | FirstPage: buffer, 67 | TotalPages: totalPages, 68 | }, nil 69 | } 70 | 71 | func XorBytes(a []byte, b byte) []byte { 72 | result := make([]byte, len(a)) 73 | for i := range a { 74 | result[i] = a[i] ^ b 75 | } 76 | return result 77 | } 78 | 79 | 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 { 80 | if len(key) != KeySize { 81 | return false 82 | } 83 | 84 | _, macKey := deriveKeys(key, salt) 85 | 86 | mac := hmac.New(hashFunc, macKey) 87 | dataEnd := pageSize - reserve + IVSize 88 | mac.Write(page1[SaltSize:dataEnd]) 89 | 90 | pageNoBytes := make([]byte, 4) 91 | binary.LittleEndian.PutUint32(pageNoBytes, 1) 92 | mac.Write(pageNoBytes) 93 | 94 | calculatedMAC := mac.Sum(nil) 95 | storedMAC := page1[dataEnd : dataEnd+hmacSize] 96 | 97 | return hmac.Equal(calculatedMAC, storedMAC) 98 | } 99 | 100 | func DecryptPage(pageBuf []byte, encKey []byte, macKey []byte, pageNum int64, hashFunc func() hash.Hash, hmacSize int, reserve int, pageSize int) ([]byte, error) { 101 | offset := 0 102 | if pageNum == 0 { 103 | offset = SaltSize 104 | } 105 | 106 | mac := hmac.New(hashFunc, macKey) 107 | mac.Write(pageBuf[offset : pageSize-reserve+IVSize]) 108 | 109 | pageNoBytes := make([]byte, 4) 110 | binary.LittleEndian.PutUint32(pageNoBytes, uint32(pageNum+1)) 111 | mac.Write(pageNoBytes) 112 | 113 | hashMac := mac.Sum(nil) 114 | 115 | hashMacStartOffset := pageSize - reserve + IVSize 116 | hashMacEndOffset := hashMacStartOffset + hmacSize 117 | 118 | if !bytes.Equal(hashMac, pageBuf[hashMacStartOffset:hashMacEndOffset]) { 119 | return nil, errors.ErrDecryptHashVerificationFailed 120 | } 121 | 122 | iv := pageBuf[pageSize-reserve : pageSize-reserve+IVSize] 123 | block, err := aes.NewCipher(encKey) 124 | if err != nil { 125 | return nil, errors.DecryptCreateCipherFailed(err) 126 | } 127 | 128 | mode := cipher.NewCBCDecrypter(block, iv) 129 | 130 | encrypted := make([]byte, pageSize-reserve-offset) 131 | copy(encrypted, pageBuf[offset:pageSize-reserve]) 132 | 133 | mode.CryptBlocks(encrypted, encrypted) 134 | 135 | decryptedPage := append(encrypted, pageBuf[pageSize-reserve:pageSize]...) 136 | 137 | return decryptedPage, nil 138 | } 139 | -------------------------------------------------------------------------------- /internal/wechat/decrypt/darwin/v3.go: -------------------------------------------------------------------------------- 1 | package darwin 2 | 3 | import ( 4 | "context" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "hash" 8 | "io" 9 | "os" 10 | 11 | "github.com/sjzar/chatlog/internal/errors" 12 | "github.com/sjzar/chatlog/internal/wechat/decrypt/common" 13 | 14 | "golang.org/x/crypto/pbkdf2" 15 | ) 16 | 17 | // 常量定义 18 | const ( 19 | V3PageSize = 1024 20 | HmacSHA1Size = 20 21 | ) 22 | 23 | // V3Decryptor 实现 macOS V3 版本的解密器 24 | type V3Decryptor struct { 25 | // macOS V3 特定参数 26 | hmacSize int 27 | hashFunc func() hash.Hash 28 | reserve int 29 | pageSize int 30 | version string 31 | } 32 | 33 | // NewV3Decryptor 创建 macOS V3 解密器 34 | func NewV3Decryptor() *V3Decryptor { 35 | hashFunc := sha1.New 36 | hmacSize := HmacSHA1Size 37 | reserve := common.IVSize + hmacSize 38 | if reserve%common.AESBlockSize != 0 { 39 | reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize 40 | } 41 | 42 | return &V3Decryptor{ 43 | hmacSize: hmacSize, 44 | hashFunc: hashFunc, 45 | reserve: reserve, 46 | pageSize: V3PageSize, 47 | version: "macOS v3", 48 | } 49 | } 50 | 51 | // deriveKeys 派生 MAC 密钥 52 | // 注意:macOS V3 版本直接使用提供的密钥作为加密密钥,不进行 PBKDF2 派生 53 | func (d *V3Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) { 54 | // 对于 macOS V3,直接使用密钥作为加密密钥 55 | encKey := key 56 | 57 | // 生成 MAC 密钥 58 | macSalt := common.XorBytes(salt, 0x3a) 59 | macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc) 60 | 61 | return encKey, macKey 62 | } 63 | 64 | // Validate 验证密钥是否有效 65 | func (d *V3Decryptor) Validate(page1 []byte, key []byte) bool { 66 | if len(page1) < d.pageSize || len(key) != common.KeySize { 67 | return false 68 | } 69 | 70 | salt := page1[:common.SaltSize] 71 | return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys) 72 | } 73 | 74 | // Decrypt 解密数据库 75 | func (d *V3Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error { 76 | // 解码密钥 77 | key, err := hex.DecodeString(hexKey) 78 | if err != nil { 79 | return errors.DecodeKeyFailed(err) 80 | } 81 | 82 | // 打开数据库文件并读取基本信息 83 | dbInfo, err := common.OpenDBFile(dbfile, d.pageSize) 84 | if err != nil { 85 | return err 86 | } 87 | 88 | // 验证密钥 89 | if !d.Validate(dbInfo.FirstPage, key) { 90 | return errors.ErrDecryptIncorrectKey 91 | } 92 | 93 | // 计算密钥 94 | encKey, macKey := d.deriveKeys(key, dbInfo.Salt) 95 | 96 | // 打开数据库文件 97 | dbFile, err := os.Open(dbfile) 98 | if err != nil { 99 | return errors.OpenFileFailed(dbfile, err) 100 | } 101 | defer dbFile.Close() 102 | 103 | // 写入 SQLite 头 104 | _, err = output.Write([]byte(common.SQLiteHeader)) 105 | if err != nil { 106 | return errors.WriteOutputFailed(err) 107 | } 108 | 109 | // 处理每一页 110 | pageBuf := make([]byte, d.pageSize) 111 | 112 | for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ { 113 | // 检查是否取消 114 | select { 115 | case <-ctx.Done(): 116 | return errors.ErrDecryptOperationCanceled 117 | default: 118 | // 继续处理 119 | } 120 | 121 | // 读取一页 122 | n, err := io.ReadFull(dbFile, pageBuf) 123 | if err != nil { 124 | if err == io.EOF || err == io.ErrUnexpectedEOF { 125 | // 处理最后一部分页面 126 | if n > 0 { 127 | break 128 | } 129 | } 130 | return errors.ReadFileFailed(dbfile, err) 131 | } 132 | 133 | // 检查页面是否全为零 134 | allZeros := true 135 | for _, b := range pageBuf { 136 | if b != 0 { 137 | allZeros = false 138 | break 139 | } 140 | } 141 | 142 | if allZeros { 143 | // 写入零页面 144 | _, err = output.Write(pageBuf) 145 | if err != nil { 146 | return errors.WriteOutputFailed(err) 147 | } 148 | continue 149 | } 150 | 151 | // 解密页面 152 | decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize) 153 | if err != nil { 154 | return err 155 | } 156 | 157 | // 写入解密后的页面 158 | _, err = output.Write(decryptedData) 159 | if err != nil { 160 | return errors.WriteOutputFailed(err) 161 | } 162 | } 163 | 164 | return nil 165 | } 166 | 167 | // GetPageSize 返回页面大小 168 | func (d *V3Decryptor) GetPageSize() int { 169 | return d.pageSize 170 | } 171 | 172 | // GetReserve 返回保留字节数 173 | func (d *V3Decryptor) GetReserve() int { 174 | return d.reserve 175 | } 176 | 177 | // GetHMACSize 返回HMAC大小 178 | func (d *V3Decryptor) GetHMACSize() int { 179 | return d.hmacSize 180 | } 181 | 182 | // GetVersion 返回解密器版本 183 | func (d *V3Decryptor) GetVersion() string { 184 | return d.version 185 | } 186 | -------------------------------------------------------------------------------- /internal/wechat/decrypt/darwin/v4.go: -------------------------------------------------------------------------------- 1 | package darwin 2 | 3 | import ( 4 | "context" 5 | "crypto/sha512" 6 | "encoding/hex" 7 | "hash" 8 | "io" 9 | "os" 10 | 11 | "github.com/sjzar/chatlog/internal/errors" 12 | "github.com/sjzar/chatlog/internal/wechat/decrypt/common" 13 | 14 | "golang.org/x/crypto/pbkdf2" 15 | ) 16 | 17 | // Darwin Version 4 same as WIndows Version 4 18 | 19 | // V4 版本特定常量 20 | const ( 21 | V4PageSize = 4096 22 | V4IterCount = 256000 23 | HmacSHA512Size = 64 24 | ) 25 | 26 | // V4Decryptor 实现Windows V4版本的解密器 27 | type V4Decryptor struct { 28 | // V4 特定参数 29 | iterCount int 30 | hmacSize int 31 | hashFunc func() hash.Hash 32 | reserve int 33 | pageSize int 34 | version string 35 | } 36 | 37 | // NewV4Decryptor 创建Windows V4解密器 38 | func NewV4Decryptor() *V4Decryptor { 39 | hashFunc := sha512.New 40 | hmacSize := HmacSHA512Size 41 | reserve := common.IVSize + hmacSize 42 | if reserve%common.AESBlockSize != 0 { 43 | reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize 44 | } 45 | 46 | return &V4Decryptor{ 47 | iterCount: V4IterCount, 48 | hmacSize: hmacSize, 49 | hashFunc: hashFunc, 50 | reserve: reserve, 51 | pageSize: V4PageSize, 52 | version: "macOS v4", 53 | } 54 | } 55 | 56 | // deriveKeys 派生加密密钥和MAC密钥 57 | func (d *V4Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) { 58 | // 生成加密密钥 59 | encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc) 60 | 61 | // 生成MAC密钥 62 | macSalt := common.XorBytes(salt, 0x3a) 63 | macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc) 64 | 65 | return encKey, macKey 66 | } 67 | 68 | // Validate 验证密钥是否有效 69 | func (d *V4Decryptor) Validate(page1 []byte, key []byte) bool { 70 | if len(page1) < d.pageSize || len(key) != common.KeySize { 71 | return false 72 | } 73 | 74 | salt := page1[:common.SaltSize] 75 | return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys) 76 | } 77 | 78 | // Decrypt 解密数据库 79 | func (d *V4Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error { 80 | // 解码密钥 81 | key, err := hex.DecodeString(hexKey) 82 | if err != nil { 83 | return errors.DecodeKeyFailed(err) 84 | } 85 | 86 | // 打开数据库文件并读取基本信息 87 | dbInfo, err := common.OpenDBFile(dbfile, d.pageSize) 88 | if err != nil { 89 | return err 90 | } 91 | 92 | // 验证密钥 93 | if !d.Validate(dbInfo.FirstPage, key) { 94 | return errors.ErrDecryptIncorrectKey 95 | } 96 | 97 | // 计算密钥 98 | encKey, macKey := d.deriveKeys(key, dbInfo.Salt) 99 | 100 | // 打开数据库文件 101 | dbFile, err := os.Open(dbfile) 102 | if err != nil { 103 | return errors.OpenFileFailed(dbfile, err) 104 | } 105 | defer dbFile.Close() 106 | 107 | // 写入SQLite头 108 | _, err = output.Write([]byte(common.SQLiteHeader)) 109 | if err != nil { 110 | return errors.WriteOutputFailed(err) 111 | } 112 | 113 | // 处理每一页 114 | pageBuf := make([]byte, d.pageSize) 115 | 116 | for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ { 117 | // 检查是否取消 118 | select { 119 | case <-ctx.Done(): 120 | return errors.ErrDecryptOperationCanceled 121 | default: 122 | // 继续处理 123 | } 124 | 125 | // 读取一页 126 | n, err := io.ReadFull(dbFile, pageBuf) 127 | if err != nil { 128 | if err == io.EOF || err == io.ErrUnexpectedEOF { 129 | // 处理最后一部分页面 130 | if n > 0 { 131 | break 132 | } 133 | } 134 | return errors.ReadFileFailed(dbfile, err) 135 | } 136 | 137 | // 检查页面是否全为零 138 | allZeros := true 139 | for _, b := range pageBuf { 140 | if b != 0 { 141 | allZeros = false 142 | break 143 | } 144 | } 145 | 146 | if allZeros { 147 | // 写入零页面 148 | _, err = output.Write(pageBuf) 149 | if err != nil { 150 | return errors.WriteOutputFailed(err) 151 | } 152 | continue 153 | } 154 | 155 | // 解密页面 156 | decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize) 157 | if err != nil { 158 | return err 159 | } 160 | 161 | // 写入解密后的页面 162 | _, err = output.Write(decryptedData) 163 | if err != nil { 164 | return errors.WriteOutputFailed(err) 165 | } 166 | } 167 | 168 | return nil 169 | } 170 | 171 | // GetPageSize 返回页面大小 172 | func (d *V4Decryptor) GetPageSize() int { 173 | return d.pageSize 174 | } 175 | 176 | // GetReserve 返回保留字节数 177 | func (d *V4Decryptor) GetReserve() int { 178 | return d.reserve 179 | } 180 | 181 | // GetHMACSize 返回HMAC大小 182 | func (d *V4Decryptor) GetHMACSize() int { 183 | return d.hmacSize 184 | } 185 | 186 | // GetVersion 返回解密器版本 187 | func (d *V4Decryptor) GetVersion() string { 188 | return d.version 189 | } 190 | 191 | // GetIterCount 返回迭代次数(Windows特有) 192 | func (d *V4Decryptor) GetIterCount() int { 193 | return d.iterCount 194 | } 195 | -------------------------------------------------------------------------------- /internal/wechat/decrypt/decryptor.go: -------------------------------------------------------------------------------- 1 | package decrypt 2 | 3 | import ( 4 | "context" 5 | "io" 6 | 7 | "github.com/sjzar/chatlog/internal/errors" 8 | "github.com/sjzar/chatlog/internal/wechat/decrypt/darwin" 9 | "github.com/sjzar/chatlog/internal/wechat/decrypt/windows" 10 | ) 11 | 12 | // Decryptor 定义数据库解密的接口 13 | type Decryptor interface { 14 | // Decrypt 解密数据库 15 | Decrypt(ctx context.Context, dbfile string, key string, output io.Writer) error 16 | 17 | // Validate 验证密钥是否有效 18 | Validate(page1 []byte, key []byte) bool 19 | 20 | // GetPageSize 返回页面大小 21 | GetPageSize() int 22 | 23 | // GetReserve 返回保留字节数 24 | GetReserve() int 25 | 26 | // GetHMACSize 返回HMAC大小 27 | GetHMACSize() int 28 | 29 | // GetVersion 返回解密器版本 30 | GetVersion() string 31 | } 32 | 33 | // NewDecryptor 创建一个新的解密器 34 | func NewDecryptor(platform string, version int) (Decryptor, error) { 35 | // 根据平台返回对应的实现 36 | switch { 37 | case platform == "windows" && version == 3: 38 | return windows.NewV3Decryptor(), nil 39 | case platform == "windows" && version == 4: 40 | return windows.NewV4Decryptor(), nil 41 | case platform == "darwin" && version == 3: 42 | return darwin.NewV3Decryptor(), nil 43 | case platform == "darwin" && version == 4: 44 | return darwin.NewV4Decryptor(), nil 45 | default: 46 | return nil, errors.PlatformUnsupported(platform, version) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /internal/wechat/decrypt/validator.go: -------------------------------------------------------------------------------- 1 | package decrypt 2 | 3 | import ( 4 | "path/filepath" 5 | 6 | "github.com/sjzar/chatlog/internal/wechat/decrypt/common" 7 | ) 8 | 9 | type Validator struct { 10 | platform string 11 | version int 12 | dbPath string 13 | decryptor Decryptor 14 | dbFile *common.DBFile 15 | } 16 | 17 | // NewValidator 创建一个仅用于验证的验证器 18 | func NewValidator(platform string, version int, dataDir string) (*Validator, error) { 19 | dbFile := GetSimpleDBFile(platform, version) 20 | dbPath := filepath.Join(dataDir + "/" + dbFile) 21 | return NewValidatorWithFile(platform, version, dbPath) 22 | } 23 | 24 | func NewValidatorWithFile(platform string, version int, dbPath string) (*Validator, error) { 25 | decryptor, err := NewDecryptor(platform, version) 26 | if err != nil { 27 | return nil, err 28 | } 29 | d, err := common.OpenDBFile(dbPath, decryptor.GetPageSize()) 30 | if err != nil { 31 | return nil, err 32 | } 33 | 34 | return &Validator{ 35 | platform: platform, 36 | version: version, 37 | dbPath: dbPath, 38 | decryptor: decryptor, 39 | dbFile: d, 40 | }, nil 41 | } 42 | 43 | func (v *Validator) Validate(key []byte) bool { 44 | return v.decryptor.Validate(v.dbFile.FirstPage, key) 45 | } 46 | 47 | func GetSimpleDBFile(platform string, version int) string { 48 | switch { 49 | case platform == "windows" && version == 3: 50 | return "Msg\\Misc.db" 51 | case platform == "windows" && version == 4: 52 | return "db_storage\\message\\message_0.db" 53 | case platform == "darwin" && version == 3: 54 | return "Message/msg_0.db" 55 | case platform == "darwin" && version == 4: 56 | return "db_storage/message/message_0.db" 57 | } 58 | return "" 59 | 60 | } 61 | -------------------------------------------------------------------------------- /internal/wechat/decrypt/windows/v3.go: -------------------------------------------------------------------------------- 1 | package windows 2 | 3 | import ( 4 | "context" 5 | "crypto/sha1" 6 | "encoding/hex" 7 | "hash" 8 | "io" 9 | "os" 10 | 11 | "github.com/sjzar/chatlog/internal/errors" 12 | "github.com/sjzar/chatlog/internal/wechat/decrypt/common" 13 | 14 | "golang.org/x/crypto/pbkdf2" 15 | ) 16 | 17 | // V3 版本特定常量 18 | const ( 19 | PageSize = 4096 20 | V3IterCount = 64000 21 | HmacSHA1Size = 20 22 | ) 23 | 24 | // V3Decryptor 实现Windows V3版本的解密器 25 | type V3Decryptor struct { 26 | // V3 特定参数 27 | iterCount int 28 | hmacSize int 29 | hashFunc func() hash.Hash 30 | reserve int 31 | pageSize int 32 | version string 33 | } 34 | 35 | // NewV3Decryptor 创建Windows V3解密器 36 | func NewV3Decryptor() *V3Decryptor { 37 | hashFunc := sha1.New 38 | hmacSize := HmacSHA1Size 39 | reserve := common.IVSize + hmacSize 40 | if reserve%common.AESBlockSize != 0 { 41 | reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize 42 | } 43 | 44 | return &V3Decryptor{ 45 | iterCount: V3IterCount, 46 | hmacSize: hmacSize, 47 | hashFunc: hashFunc, 48 | reserve: reserve, 49 | pageSize: PageSize, 50 | version: "Windows v3", 51 | } 52 | } 53 | 54 | // deriveKeys 派生加密密钥和MAC密钥 55 | func (d *V3Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) { 56 | // 生成加密密钥 57 | encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc) 58 | 59 | // 生成MAC密钥 60 | macSalt := common.XorBytes(salt, 0x3a) 61 | macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc) 62 | 63 | return encKey, macKey 64 | } 65 | 66 | // Validate 验证密钥是否有效 67 | func (d *V3Decryptor) Validate(page1 []byte, key []byte) bool { 68 | if len(page1) < d.pageSize || len(key) != common.KeySize { 69 | return false 70 | } 71 | 72 | salt := page1[:common.SaltSize] 73 | return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys) 74 | } 75 | 76 | // Decrypt 解密数据库 77 | func (d *V3Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error { 78 | // 解码密钥 79 | key, err := hex.DecodeString(hexKey) 80 | if err != nil { 81 | return errors.DecodeKeyFailed(err) 82 | } 83 | 84 | // 打开数据库文件并读取基本信息 85 | dbInfo, err := common.OpenDBFile(dbfile, d.pageSize) 86 | if err != nil { 87 | return err 88 | } 89 | 90 | // 验证密钥 91 | if !d.Validate(dbInfo.FirstPage, key) { 92 | return errors.ErrDecryptIncorrectKey 93 | } 94 | 95 | // 计算密钥 96 | encKey, macKey := d.deriveKeys(key, dbInfo.Salt) 97 | 98 | // 打开数据库文件 99 | dbFile, err := os.Open(dbfile) 100 | if err != nil { 101 | return errors.OpenFileFailed(dbfile, err) 102 | } 103 | defer dbFile.Close() 104 | 105 | // 写入SQLite头 106 | _, err = output.Write([]byte(common.SQLiteHeader)) 107 | if err != nil { 108 | return errors.WriteOutputFailed(err) 109 | } 110 | 111 | // 处理每一页 112 | pageBuf := make([]byte, d.pageSize) 113 | 114 | for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ { 115 | // 检查是否取消 116 | select { 117 | case <-ctx.Done(): 118 | return errors.ErrDecryptOperationCanceled 119 | default: 120 | // 继续处理 121 | } 122 | 123 | // 读取一页 124 | n, err := io.ReadFull(dbFile, pageBuf) 125 | if err != nil { 126 | if err == io.EOF || err == io.ErrUnexpectedEOF { 127 | // 处理最后一部分页面 128 | if n > 0 { 129 | break 130 | } 131 | } 132 | return errors.ReadFileFailed(dbfile, err) 133 | } 134 | 135 | // 检查页面是否全为零 136 | allZeros := true 137 | for _, b := range pageBuf { 138 | if b != 0 { 139 | allZeros = false 140 | break 141 | } 142 | } 143 | 144 | if allZeros { 145 | // 写入零页面 146 | _, err = output.Write(pageBuf) 147 | if err != nil { 148 | return errors.WriteOutputFailed(err) 149 | } 150 | continue 151 | } 152 | 153 | // 解密页面 154 | decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize) 155 | if err != nil { 156 | return err 157 | } 158 | 159 | // 写入解密后的页面 160 | _, err = output.Write(decryptedData) 161 | if err != nil { 162 | return errors.WriteOutputFailed(err) 163 | } 164 | } 165 | 166 | return nil 167 | } 168 | 169 | // GetPageSize 返回页面大小 170 | func (d *V3Decryptor) GetPageSize() int { 171 | return d.pageSize 172 | } 173 | 174 | // GetReserve 返回保留字节数 175 | func (d *V3Decryptor) GetReserve() int { 176 | return d.reserve 177 | } 178 | 179 | // GetHMACSize 返回HMAC大小 180 | func (d *V3Decryptor) GetHMACSize() int { 181 | return d.hmacSize 182 | } 183 | 184 | // GetVersion 返回解密器版本 185 | func (d *V3Decryptor) GetVersion() string { 186 | return d.version 187 | } 188 | 189 | // GetIterCount 返回迭代次数(Windows特有) 190 | func (d *V3Decryptor) GetIterCount() int { 191 | return d.iterCount 192 | } 193 | -------------------------------------------------------------------------------- /internal/wechat/decrypt/windows/v4.go: -------------------------------------------------------------------------------- 1 | package windows 2 | 3 | import ( 4 | "context" 5 | "crypto/sha512" 6 | "encoding/hex" 7 | "hash" 8 | "io" 9 | "os" 10 | 11 | "github.com/sjzar/chatlog/internal/errors" 12 | "github.com/sjzar/chatlog/internal/wechat/decrypt/common" 13 | 14 | "golang.org/x/crypto/pbkdf2" 15 | ) 16 | 17 | // V4 版本特定常量 18 | const ( 19 | V4IterCount = 256000 20 | HmacSHA512Size = 64 21 | ) 22 | 23 | // V4Decryptor 实现Windows V4版本的解密器 24 | type V4Decryptor struct { 25 | // V4 特定参数 26 | iterCount int 27 | hmacSize int 28 | hashFunc func() hash.Hash 29 | reserve int 30 | pageSize int 31 | version string 32 | } 33 | 34 | // NewV4Decryptor 创建Windows V4解密器 35 | func NewV4Decryptor() *V4Decryptor { 36 | hashFunc := sha512.New 37 | hmacSize := HmacSHA512Size 38 | reserve := common.IVSize + hmacSize 39 | if reserve%common.AESBlockSize != 0 { 40 | reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize 41 | } 42 | 43 | return &V4Decryptor{ 44 | iterCount: V4IterCount, 45 | hmacSize: hmacSize, 46 | hashFunc: hashFunc, 47 | reserve: reserve, 48 | pageSize: PageSize, 49 | version: "Windows v4", 50 | } 51 | } 52 | 53 | // deriveKeys 派生加密密钥和MAC密钥 54 | func (d *V4Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) { 55 | // 生成加密密钥 56 | encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc) 57 | 58 | // 生成MAC密钥 59 | macSalt := common.XorBytes(salt, 0x3a) 60 | macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc) 61 | 62 | return encKey, macKey 63 | } 64 | 65 | // Validate 验证密钥是否有效 66 | func (d *V4Decryptor) Validate(page1 []byte, key []byte) bool { 67 | if len(page1) < d.pageSize || len(key) != common.KeySize { 68 | return false 69 | } 70 | 71 | salt := page1[:common.SaltSize] 72 | return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys) 73 | } 74 | 75 | // Decrypt 解密数据库 76 | func (d *V4Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error { 77 | // 解码密钥 78 | key, err := hex.DecodeString(hexKey) 79 | if err != nil { 80 | return errors.DecodeKeyFailed(err) 81 | } 82 | 83 | // 打开数据库文件并读取基本信息 84 | dbInfo, err := common.OpenDBFile(dbfile, d.pageSize) 85 | if err != nil { 86 | return err 87 | } 88 | 89 | // 验证密钥 90 | if !d.Validate(dbInfo.FirstPage, key) { 91 | return errors.ErrDecryptIncorrectKey 92 | } 93 | 94 | // 计算密钥 95 | encKey, macKey := d.deriveKeys(key, dbInfo.Salt) 96 | 97 | // 打开数据库文件 98 | dbFile, err := os.Open(dbfile) 99 | if err != nil { 100 | return errors.OpenFileFailed(dbfile, err) 101 | } 102 | defer dbFile.Close() 103 | 104 | // 写入SQLite头 105 | _, err = output.Write([]byte(common.SQLiteHeader)) 106 | if err != nil { 107 | return errors.WriteOutputFailed(err) 108 | } 109 | 110 | // 处理每一页 111 | pageBuf := make([]byte, d.pageSize) 112 | 113 | for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ { 114 | // 检查是否取消 115 | select { 116 | case <-ctx.Done(): 117 | return errors.ErrDecryptOperationCanceled 118 | default: 119 | // 继续处理 120 | } 121 | 122 | // 读取一页 123 | n, err := io.ReadFull(dbFile, pageBuf) 124 | if err != nil { 125 | if err == io.EOF || err == io.ErrUnexpectedEOF { 126 | // 处理最后一部分页面 127 | if n > 0 { 128 | break 129 | } 130 | } 131 | return errors.ReadFileFailed(dbfile, err) 132 | } 133 | 134 | // 检查页面是否全为零 135 | allZeros := true 136 | for _, b := range pageBuf { 137 | if b != 0 { 138 | allZeros = false 139 | break 140 | } 141 | } 142 | 143 | if allZeros { 144 | // 写入零页面 145 | _, err = output.Write(pageBuf) 146 | if err != nil { 147 | return errors.WriteOutputFailed(err) 148 | } 149 | continue 150 | } 151 | 152 | // 解密页面 153 | decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize) 154 | if err != nil { 155 | return err 156 | } 157 | 158 | // 写入解密后的页面 159 | _, err = output.Write(decryptedData) 160 | if err != nil { 161 | return errors.WriteOutputFailed(err) 162 | } 163 | } 164 | 165 | return nil 166 | } 167 | 168 | // GetPageSize 返回页面大小 169 | func (d *V4Decryptor) GetPageSize() int { 170 | return d.pageSize 171 | } 172 | 173 | // GetReserve 返回保留字节数 174 | func (d *V4Decryptor) GetReserve() int { 175 | return d.reserve 176 | } 177 | 178 | // GetHMACSize 返回HMAC大小 179 | func (d *V4Decryptor) GetHMACSize() int { 180 | return d.hmacSize 181 | } 182 | 183 | // GetVersion 返回解密器版本 184 | func (d *V4Decryptor) GetVersion() string { 185 | return d.version 186 | } 187 | 188 | // GetIterCount 返回迭代次数(Windows特有) 189 | func (d *V4Decryptor) GetIterCount() int { 190 | return d.iterCount 191 | } 192 | -------------------------------------------------------------------------------- /internal/wechat/key/darwin/glance/glance.go: -------------------------------------------------------------------------------- 1 | package glance 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "io" 7 | "os" 8 | "os/exec" 9 | "path/filepath" 10 | "time" 11 | 12 | "github.com/rs/zerolog/log" 13 | "github.com/sjzar/chatlog/internal/errors" 14 | ) 15 | 16 | // FIXME 按照 region 读取效率较低,512MB 内存读取耗时约 18s 17 | 18 | type Glance struct { 19 | PID uint32 20 | MemRegions []MemRegion 21 | pipePath string 22 | data []byte 23 | } 24 | 25 | func NewGlance(pid uint32) *Glance { 26 | return &Glance{ 27 | PID: pid, 28 | pipePath: filepath.Join(os.TempDir(), fmt.Sprintf("chatlog_pipe_%d", time.Now().UnixNano())), 29 | } 30 | } 31 | 32 | func (g *Glance) Read() ([]byte, error) { 33 | if g.data != nil { 34 | return g.data, nil 35 | } 36 | 37 | regions, err := GetVmmap(g.PID) 38 | if err != nil { 39 | return nil, err 40 | } 41 | g.MemRegions = MemRegionsFilter(regions) 42 | 43 | if len(g.MemRegions) == 0 { 44 | return nil, errors.ErrNoMemoryRegionsFound 45 | } 46 | 47 | region := g.MemRegions[0] 48 | 49 | // 1. Create pipe file 50 | if err := exec.Command("mkfifo", g.pipePath).Run(); err != nil { 51 | return nil, errors.CreatePipeFileFailed(err) 52 | } 53 | defer os.Remove(g.pipePath) 54 | 55 | // Start a goroutine to read from the pipe 56 | dataCh := make(chan []byte, 1) 57 | errCh := make(chan error, 1) 58 | go func() { 59 | // Open pipe for reading 60 | file, err := os.OpenFile(g.pipePath, os.O_RDONLY, 0600) 61 | if err != nil { 62 | errCh <- errors.OpenPipeFileFailed(err) 63 | return 64 | } 65 | defer file.Close() 66 | 67 | // Read all data from pipe 68 | data, err := io.ReadAll(file) 69 | if err != nil { 70 | errCh <- errors.ReadPipeFileFailed(err) 71 | return 72 | } 73 | dataCh <- data 74 | }() 75 | 76 | // 2 & 3. Execute lldb command to read memory directly with all parameters 77 | size := region.End - region.Start 78 | lldbCmd := fmt.Sprintf("lldb -p %d -o \"memory read --binary --force --outfile %s --count %d 0x%x\" -o \"quit\"", 79 | g.PID, g.pipePath, size, region.Start) 80 | 81 | cmd := exec.Command("bash", "-c", lldbCmd) 82 | 83 | // Set up stdout pipe for monitoring (optional) 84 | stdout, err := cmd.StdoutPipe() 85 | if err != nil { 86 | return nil, err 87 | } 88 | 89 | // Start the command 90 | if err := cmd.Start(); err != nil { 91 | return nil, errors.RunCmdFailed(err) 92 | } 93 | 94 | // Monitor lldb output (optional) 95 | go func() { 96 | scanner := bufio.NewScanner(stdout) 97 | for scanner.Scan() { 98 | // Uncomment for debugging: 99 | // fmt.Println(scanner.Text()) 100 | } 101 | }() 102 | 103 | // Wait for data with timeout 104 | select { 105 | case data := <-dataCh: 106 | g.data = data 107 | case err := <-errCh: 108 | return nil, errors.ReadMemoryFailed(err) 109 | case <-time.After(30 * time.Second): 110 | cmd.Process.Kill() 111 | return nil, errors.ErrReadMemoryTimeout 112 | } 113 | 114 | // Wait for the command to finish 115 | if err := cmd.Wait(); err != nil { 116 | // We already have the data, so just log the error 117 | log.Err(err).Msg("lldb process exited with error") 118 | } 119 | 120 | return g.data, nil 121 | } 122 | -------------------------------------------------------------------------------- /internal/wechat/key/darwin/glance/sip.go: -------------------------------------------------------------------------------- 1 | package glance 2 | 3 | import ( 4 | "os/exec" 5 | "strings" 6 | ) 7 | 8 | // IsSIPDisabled checks if System Integrity Protection (SIP) is disabled on macOS. 9 | // Returns true if SIP is disabled, false if it's enabled or if the status cannot be determined. 10 | func IsSIPDisabled() bool { 11 | // Run the csrutil status command to check SIP status 12 | cmd := exec.Command("csrutil", "status") 13 | output, err := cmd.CombinedOutput() 14 | if err != nil { 15 | // If there's an error running the command, assume SIP is enabled 16 | return false 17 | } 18 | 19 | // Convert output to string and check if SIP is disabled 20 | outputStr := strings.ToLower(string(output)) 21 | 22 | // $ csrutil status 23 | // System Integrity Protection status: disabled. 24 | 25 | // If the output contains "disabled", SIP is disabled 26 | if strings.Contains(outputStr, "system integrity protection status: disabled") { 27 | return true 28 | } 29 | 30 | // Check for partial SIP disabling - some configurations might have specific protections disabled 31 | if strings.Contains(outputStr, "disabled") && strings.Contains(outputStr, "debugging") { 32 | return true 33 | } 34 | 35 | // By default, assume SIP is enabled 36 | return false 37 | } 38 | -------------------------------------------------------------------------------- /internal/wechat/key/darwin/glance/vmmap.go: -------------------------------------------------------------------------------- 1 | package glance 2 | 3 | import ( 4 | "bufio" 5 | "fmt" 6 | "os/exec" 7 | "regexp" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/sjzar/chatlog/internal/errors" 12 | ) 13 | 14 | const ( 15 | FilterRegionType = "MALLOC_NANO" 16 | FilterSHRMOD = "SM=PRV" 17 | CommandVmmap = "vmmap" 18 | ) 19 | 20 | type MemRegion struct { 21 | RegionType string 22 | Start uint64 23 | End uint64 24 | VSize uint64 // Size in bytes 25 | RSDNT uint64 // Resident memory size in bytes (new field) 26 | SHRMOD string 27 | Permissions string 28 | RegionDetail string 29 | } 30 | 31 | func GetVmmap(pid uint32) ([]MemRegion, error) { 32 | // Execute vmmap command 33 | cmd := exec.Command(CommandVmmap, "-wide", fmt.Sprintf("%d", pid)) 34 | output, err := cmd.CombinedOutput() 35 | if err != nil { 36 | return nil, errors.RunCmdFailed(err) 37 | } 38 | 39 | // Parse the output using the existing LoadVmmap function 40 | return LoadVmmap(string(output)) 41 | } 42 | 43 | func LoadVmmap(output string) ([]MemRegion, error) { 44 | var regions []MemRegion 45 | 46 | scanner := bufio.NewScanner(strings.NewReader(output)) 47 | 48 | // Skip lines until we find the header 49 | foundHeader := false 50 | for scanner.Scan() { 51 | line := scanner.Text() 52 | if strings.HasPrefix(line, "==== Writable regions for") { 53 | foundHeader = true 54 | // Skip the column headers line 55 | scanner.Scan() 56 | break 57 | } 58 | } 59 | 60 | if !foundHeader { 61 | return nil, nil // No vmmap data found 62 | } 63 | 64 | // Regular expression to parse the vmmap output lines 65 | // Format: REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL 66 | // Updated regex to capture RSDNT value (second value in brackets) 67 | 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+(.*)
The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
) 68 | 69 | // Parse each line 70 | for scanner.Scan() { 71 | line := scanner.Text() 72 | if line == "" { 73 | continue 74 | } 75 | 76 | matches := re.FindStringSubmatch(line) 77 | if len(matches) >= 9 { // Updated to check for at least 9 matches 78 | 79 | // Parse start and end addresses 80 | start, _ := strconv.ParseUint(matches[2], 16, 64) 81 | end, _ := strconv.ParseUint(matches[3], 16, 64) 82 | 83 | // Parse VSize as numeric value 84 | vsize := parseSize(matches[4]) 85 | 86 | // Parse RSDNT as numeric value (new) 87 | rsdnt := parseSize(matches[5]) 88 | 89 | region := MemRegion{ 90 | RegionType: strings.TrimSpace(matches[1]), 91 | Start: start, 92 | End: end, 93 | VSize: vsize, 94 | RSDNT: rsdnt, // Add the new RSDNT field 95 | Permissions: matches[6], // Shifted index 96 | SHRMOD: matches[7], // Shifted index 97 | RegionDetail: strings.TrimSpace(matches[8]), // Shifted index 98 | } 99 | 100 | regions = append(regions, region) 101 | } 102 | } 103 | 104 | return regions, nil 105 | } 106 | 107 | func MemRegionsFilter(regions []MemRegion) []MemRegion { 108 | var filteredRegions []MemRegion 109 | for _, region := range regions { 110 | if region.RegionType == FilterRegionType { 111 | filteredRegions = append(filteredRegions, region) 112 | } 113 | } 114 | return filteredRegions 115 | } 116 | 117 | // parseSize converts size strings like "5616K" or "128.0M" to bytes (uint64) 118 | func parseSize(sizeStr string) uint64 { 119 | // Remove any whitespace 120 | sizeStr = strings.TrimSpace(sizeStr) 121 | 122 | // Define multipliers for different units 123 | multipliers := map[string]uint64{ 124 | "B": 1, 125 | "K": 1024, 126 | "KB": 1024, 127 | "M": 1024 * 1024, 128 | "MB": 1024 * 1024, 129 | "G": 1024 * 1024 * 1024, 130 | "GB": 1024 * 1024 * 1024, 131 | } 132 | 133 | // Regular expression to match numbers with optional decimal point and unit 134 | // This will match formats like: "5616K", "128.0M", "1.5G", etc. 135 | re := regexp.MustCompile(`^(\d+(?:\.\d+)?)([KMGB]+)?
The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
) 136 | matches := re.FindStringSubmatch(sizeStr) 137 | 138 | if len(matches) < 2 { 139 | return 0 // No match found 140 | } 141 | 142 | // Parse the numeric part (which may include a decimal point) 143 | numStr := matches[1] 144 | numVal, err := strconv.ParseFloat(numStr, 64) 145 | if err != nil { 146 | return 0 147 | } 148 | 149 | // Determine the multiplier based on the unit 150 | multiplier := uint64(1) // Default if no unit specified 151 | if len(matches) >= 3 && matches[2] != "" { 152 | unit := matches[2] 153 | if m, ok := multipliers[unit]; ok { 154 | multiplier = m 155 | } 156 | } 157 | 158 | // Calculate final size in bytes (rounding to nearest integer) 159 | return uint64(numVal*float64(multiplier) + 0.5) 160 | } 161 | -------------------------------------------------------------------------------- /internal/wechat/key/extractor.go: -------------------------------------------------------------------------------- 1 | package key 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sjzar/chatlog/internal/errors" 7 | "github.com/sjzar/chatlog/internal/wechat/decrypt" 8 | "github.com/sjzar/chatlog/internal/wechat/key/darwin" 9 | "github.com/sjzar/chatlog/internal/wechat/key/windows" 10 | "github.com/sjzar/chatlog/internal/wechat/model" 11 | ) 12 | 13 | // Extractor 定义密钥提取器接口 14 | type Extractor interface { 15 | // Extract 从进程中提取密钥 16 | Extract(ctx context.Context, proc *model.Process) (string, error) 17 | 18 | // SearchKey 在内存中搜索密钥 19 | SearchKey(ctx context.Context, memory []byte) (string, bool) 20 | 21 | SetValidate(validator *decrypt.Validator) 22 | } 23 | 24 | // NewExtractor 创建适合当前平台的密钥提取器 25 | func NewExtractor(platform string, version int) (Extractor, error) { 26 | switch { 27 | case platform == "windows" && version == 3: 28 | return windows.NewV3Extractor(), nil 29 | case platform == "windows" && version == 4: 30 | return windows.NewV4Extractor(), nil 31 | case platform == "darwin" && version == 3: 32 | return darwin.NewV3Extractor(), nil 33 | case platform == "darwin" && version == 4: 34 | return darwin.NewV4Extractor(), nil 35 | default: 36 | return nil, errors.PlatformUnsupported(platform, version) 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /internal/wechat/key/windows/v3.go: -------------------------------------------------------------------------------- 1 | package windows 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sjzar/chatlog/internal/wechat/decrypt" 7 | ) 8 | 9 | type V3Extractor struct { 10 | validator *decrypt.Validator 11 | } 12 | 13 | func NewV3Extractor() *V3Extractor { 14 | return &V3Extractor{} 15 | } 16 | 17 | func (e *V3Extractor) SearchKey(ctx context.Context, memory []byte) (string, bool) { 18 | // TODO : Implement the key search logic for V3 19 | return "", false 20 | } 21 | 22 | func (e *V3Extractor) SetValidate(validator *decrypt.Validator) { 23 | e.validator = validator 24 | } 25 | -------------------------------------------------------------------------------- /internal/wechat/key/windows/v3_others.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package windows 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/sjzar/chatlog/internal/wechat/model" 9 | ) 10 | 11 | func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) { 12 | return "", nil 13 | } 14 | -------------------------------------------------------------------------------- /internal/wechat/key/windows/v4.go: -------------------------------------------------------------------------------- 1 | package windows 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sjzar/chatlog/internal/wechat/decrypt" 7 | ) 8 | 9 | type V4Extractor struct { 10 | validator *decrypt.Validator 11 | } 12 | 13 | func NewV4Extractor() *V4Extractor { 14 | return &V4Extractor{} 15 | } 16 | 17 | func (e *V4Extractor) SearchKey(ctx context.Context, memory []byte) (string, bool) { 18 | // TODO : Implement the key search logic for V4 19 | return "", false 20 | } 21 | 22 | func (e *V4Extractor) SetValidate(validator *decrypt.Validator) { 23 | e.validator = validator 24 | } 25 | -------------------------------------------------------------------------------- /internal/wechat/key/windows/v4_others.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package windows 4 | 5 | import ( 6 | "context" 7 | 8 | "github.com/sjzar/chatlog/internal/wechat/model" 9 | ) 10 | 11 | func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) { 12 | return "", nil 13 | } 14 | -------------------------------------------------------------------------------- /internal/wechat/manager.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "context" 5 | "runtime" 6 | 7 | "github.com/sjzar/chatlog/internal/errors" 8 | "github.com/sjzar/chatlog/internal/wechat/model" 9 | "github.com/sjzar/chatlog/internal/wechat/process" 10 | ) 11 | 12 | var DefaultManager *Manager 13 | 14 | func init() { 15 | DefaultManager = NewManager() 16 | DefaultManager.Load() 17 | } 18 | 19 | func Load() error { 20 | return DefaultManager.Load() 21 | } 22 | 23 | func GetAccount(name string) (*Account, error) { 24 | return DefaultManager.GetAccount(name) 25 | } 26 | 27 | func GetProcess(name string) (*model.Process, error) { 28 | return DefaultManager.GetProcess(name) 29 | } 30 | 31 | func GetAccounts() []*Account { 32 | return DefaultManager.GetAccounts() 33 | } 34 | 35 | // Manager 微信管理器 36 | type Manager struct { 37 | detector process.Detector 38 | accounts []*Account 39 | processMap map[string]*model.Process 40 | } 41 | 42 | // NewManager 创建新的微信管理器 43 | func NewManager() *Manager { 44 | return &Manager{ 45 | detector: process.NewDetector(runtime.GOOS), 46 | accounts: make([]*Account, 0), 47 | processMap: make(map[string]*model.Process), 48 | } 49 | } 50 | 51 | // Load 加载微信进程信息 52 | func (m *Manager) Load() error { 53 | // 查找微信进程 54 | processes, err := m.detector.FindProcesses() 55 | if err != nil { 56 | return err 57 | } 58 | 59 | // 转换为账号信息 60 | accounts := make([]*Account, 0, len(processes)) 61 | processMap := make(map[string]*model.Process, len(processes)) 62 | 63 | for _, p := range processes { 64 | account := NewAccount(p) 65 | 66 | accounts = append(accounts, account) 67 | if account.Name != "" { 68 | processMap[account.Name] = p 69 | } 70 | } 71 | 72 | m.accounts = accounts 73 | m.processMap = processMap 74 | 75 | return nil 76 | } 77 | 78 | // GetAccount 获取指定名称的账号 79 | func (m *Manager) GetAccount(name string) (*Account, error) { 80 | p, err := m.GetProcess(name) 81 | if err != nil { 82 | return nil, err 83 | } 84 | return NewAccount(p), nil 85 | } 86 | 87 | func (m *Manager) GetProcess(name string) (*model.Process, error) { 88 | p, ok := m.processMap[name] 89 | if !ok { 90 | return nil, errors.WeChatAccountNotFound(name) 91 | } 92 | return p, nil 93 | } 94 | 95 | // GetAccounts 获取所有账号 96 | func (m *Manager) GetAccounts() []*Account { 97 | return m.accounts 98 | } 99 | 100 | // DecryptDatabase 便捷方法:通过账号名解密数据库 101 | func (m *Manager) DecryptDatabase(ctx context.Context, accountName, dbPath, outputPath string) error { 102 | // 获取账号 103 | account, err := m.GetAccount(accountName) 104 | if err != nil { 105 | return err 106 | } 107 | 108 | // 使用账号解密数据库 109 | return account.DecryptDatabase(ctx, dbPath, outputPath) 110 | } 111 | -------------------------------------------------------------------------------- /internal/wechat/model/process.go: -------------------------------------------------------------------------------- 1 | package model 2 | 3 | type Process struct { 4 | PID uint32 5 | ExePath string 6 | Platform string 7 | Version int 8 | FullVersion string 9 | Status string 10 | DataDir string 11 | AccountName string 12 | } 13 | 14 | // 平台常量定义 15 | const ( 16 | PlatformWindows = "windows" 17 | PlatformMacOS = "darwin" 18 | ) 19 | 20 | const ( 21 | StatusInit = "" 22 | StatusOffline = "offline" 23 | StatusOnline = "online" 24 | ) 25 | -------------------------------------------------------------------------------- /internal/wechat/process/darwin/detector.go: -------------------------------------------------------------------------------- 1 | package darwin 2 | 3 | import ( 4 | "os/exec" 5 | "path/filepath" 6 | "strconv" 7 | "strings" 8 | 9 | "github.com/rs/zerolog/log" 10 | "github.com/shirou/gopsutil/v4/process" 11 | 12 | "github.com/sjzar/chatlog/internal/errors" 13 | "github.com/sjzar/chatlog/internal/wechat/model" 14 | "github.com/sjzar/chatlog/pkg/appver" 15 | ) 16 | 17 | const ( 18 | ProcessNameOfficial = "WeChat" 19 | ProcessNameBeta = "Weixin" 20 | V3DBFile = "Message/msg_0.db" 21 | V4DBFile = "db_storage/session/session.db" 22 | ) 23 | 24 | // Detector 实现 macOS 平台的进程检测器 25 | type Detector struct{} 26 | 27 | // NewDetector 创建一个新的 macOS 检测器 28 | func NewDetector() *Detector { 29 | return &Detector{} 30 | } 31 | 32 | // FindProcesses 查找所有微信进程并返回它们的信息 33 | func (d *Detector) FindProcesses() ([]*model.Process, error) { 34 | processes, err := process.Processes() 35 | if err != nil { 36 | log.Err(err).Msg("获取进程列表失败") 37 | return nil, err 38 | } 39 | 40 | var result []*model.Process 41 | for _, p := range processes { 42 | name, err := p.Name() 43 | if err != nil || (name != ProcessNameOfficial && name != ProcessNameBeta) { 44 | continue 45 | } 46 | 47 | // 获取进程信息 48 | procInfo, err := d.getProcessInfo(p) 49 | if err != nil { 50 | log.Err(err).Msgf("获取进程 %d 的信息失败", p.Pid) 51 | continue 52 | } 53 | 54 | result = append(result, procInfo) 55 | } 56 | 57 | return result, nil 58 | } 59 | 60 | // getProcessInfo 获取微信进程的详细信息 61 | func (d *Detector) getProcessInfo(p *process.Process) (*model.Process, error) { 62 | procInfo := &model.Process{ 63 | PID: uint32(p.Pid), 64 | Status: model.StatusOffline, 65 | Platform: model.PlatformMacOS, 66 | } 67 | 68 | // 获取可执行文件路径 69 | exePath, err := p.Exe() 70 | if err != nil { 71 | log.Err(err).Msg("获取可执行文件路径失败") 72 | return nil, err 73 | } 74 | procInfo.ExePath = exePath 75 | 76 | // 获取版本信息 77 | // 注意:macOS 的版本获取方式可能与 Windows 不同 78 | versionInfo, err := appver.New(exePath) 79 | if err != nil { 80 | log.Err(err).Msg("获取版本信息失败") 81 | procInfo.Version = 3 82 | procInfo.FullVersion = "3.0.0" 83 | } else { 84 | procInfo.Version = versionInfo.Version 85 | procInfo.FullVersion = versionInfo.FullVersion 86 | } 87 | 88 | // 初始化附加信息(数据目录、账户名) 89 | if err := d.initializeProcessInfo(p, procInfo); err != nil { 90 | log.Err(err).Msg("初始化进程信息失败") 91 | // 即使初始化失败也返回部分信息 92 | } 93 | 94 | return procInfo, nil 95 | } 96 | 97 | // initializeProcessInfo 获取进程的数据目录和账户名 98 | func (d *Detector) initializeProcessInfo(p *process.Process, info *model.Process) error { 99 | // 使用 lsof 命令获取进程打开的文件 100 | files, err := d.getOpenFiles(int(p.Pid)) 101 | if err != nil { 102 | log.Err(err).Msg("获取打开的文件失败") 103 | return err 104 | } 105 | 106 | dbPath := V3DBFile 107 | if info.Version == 4 { 108 | dbPath = V4DBFile 109 | } 110 | 111 | for _, filePath := range files { 112 | if strings.Contains(filePath, dbPath) { 113 | parts := strings.Split(filePath, string(filepath.Separator)) 114 | if len(parts) < 4 { 115 | log.Debug().Msg("无效的文件路径格式: " + filePath) 116 | continue 117 | } 118 | 119 | // v3: 120 | // /Users/sarv/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9/<id>/Message/msg_0.db 121 | // v4: 122 | // /Users/sarv/Library/Containers/com.tencent.xWeChat/Data/Documents/xwechat_files/<id>/db_storage/message/message_0.db 123 | 124 | info.Status = model.StatusOnline 125 | if info.Version == 4 { 126 | info.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator)) 127 | info.AccountName = parts[len(parts)-4] 128 | } else { 129 | info.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator)) 130 | info.AccountName = parts[len(parts)-3] 131 | } 132 | return nil 133 | } 134 | } 135 | 136 | return nil 137 | } 138 | 139 | // getOpenFiles 使用 lsof 命令获取进程打开的文件列表 140 | func (d *Detector) getOpenFiles(pid int) ([]string, error) { 141 | // 执行 lsof -p <pid> 命令,使用 -F n 选项只输出文件名 142 | cmd := exec.Command("lsof", "-p", strconv.Itoa(pid), "-F", "n") 143 | output, err := cmd.Output() 144 | if err != nil { 145 | return nil, errors.RunCmdFailed(err) 146 | } 147 | 148 | // 解析 lsof -F n 输出 149 | // 格式为: n/path/to/file 150 | lines := strings.Split(string(output), "\n") 151 | var files []string 152 | 153 | for _, line := range lines { 154 | if strings.HasPrefix(line, "n") { 155 | // 移除前缀 'n' 获取文件路径 156 | filePath := line[1:] 157 | if filePath != "" { 158 | files = append(files, filePath) 159 | } 160 | } 161 | } 162 | 163 | return files, nil 164 | } 165 | -------------------------------------------------------------------------------- /internal/wechat/process/detector.go: -------------------------------------------------------------------------------- 1 | package process 2 | 3 | import ( 4 | "github.com/sjzar/chatlog/internal/wechat/model" 5 | "github.com/sjzar/chatlog/internal/wechat/process/darwin" 6 | "github.com/sjzar/chatlog/internal/wechat/process/windows" 7 | ) 8 | 9 | type Detector interface { 10 | FindProcesses() ([]*model.Process, error) 11 | } 12 | 13 | // NewDetector 创建适合当前平台的检测器 14 | func NewDetector(platform string) Detector { 15 | // 根据平台返回对应的实现 16 | switch platform { 17 | case "windows": 18 | return windows.NewDetector() 19 | case "darwin": 20 | return darwin.NewDetector() 21 | default: 22 | // 默认返回一个空实现 23 | return &nullDetector{} 24 | } 25 | } 26 | 27 | // nullDetector 空实现 28 | type nullDetector struct{} 29 | 30 | func (d *nullDetector) FindProcesses() ([]*model.Process, error) { 31 | return nil, nil 32 | } 33 | 34 | func (d *nullDetector) GetProcessInfo(pid uint32) (*model.Process, error) { 35 | return nil, nil 36 | } 37 | -------------------------------------------------------------------------------- /internal/wechat/process/windows/detector.go: -------------------------------------------------------------------------------- 1 | package windows 2 | 3 | import ( 4 | "strings" 5 | 6 | "github.com/rs/zerolog/log" 7 | "github.com/shirou/gopsutil/v4/process" 8 | 9 | "github.com/sjzar/chatlog/internal/wechat/model" 10 | "github.com/sjzar/chatlog/pkg/appver" 11 | ) 12 | 13 | const ( 14 | V3ProcessName = "WeChat" 15 | V4ProcessName = "Weixin" 16 | V3DBFile = `Msg\Misc.db` 17 | V4DBFile = `db_storage\session\session.db` 18 | ) 19 | 20 | // Detector 实现 Windows 平台的进程检测器 21 | type Detector struct{} 22 | 23 | // NewDetector 创建一个新的 Windows 检测器 24 | func NewDetector() *Detector { 25 | return &Detector{} 26 | } 27 | 28 | // FindProcesses 查找所有微信进程并返回它们的信息 29 | func (d *Detector) FindProcesses() ([]*model.Process, error) { 30 | processes, err := process.Processes() 31 | if err != nil { 32 | log.Err(err).Msg("获取进程列表失败") 33 | return nil, err 34 | } 35 | 36 | var result []*model.Process 37 | for _, p := range processes { 38 | name, err := p.Name() 39 | name = strings.TrimSuffix(name, ".exe") 40 | if err != nil || (name != V3ProcessName && name != V4ProcessName) { 41 | continue 42 | } 43 | 44 | // v4 存在同名进程,需要继续判断 cmdline 45 | if name == V4ProcessName { 46 | cmdline, err := p.Cmdline() 47 | if err != nil { 48 | log.Err(err).Msg("获取进程命令行失败") 49 | continue 50 | } 51 | if strings.Contains(cmdline, "--") { 52 | continue 53 | } 54 | } 55 | 56 | // 获取进程信息 57 | procInfo, err := d.getProcessInfo(p) 58 | if err != nil { 59 | log.Err(err).Msgf("获取进程 %d 的信息失败", p.Pid) 60 | continue 61 | } 62 | 63 | result = append(result, procInfo) 64 | } 65 | 66 | return result, nil 67 | } 68 | 69 | // getProcessInfo 获取微信进程的详细信息 70 | func (d *Detector) getProcessInfo(p *process.Process) (*model.Process, error) { 71 | procInfo := &model.Process{ 72 | PID: uint32(p.Pid), 73 | Status: model.StatusOffline, 74 | Platform: model.PlatformWindows, 75 | } 76 | 77 | // 获取可执行文件路径 78 | exePath, err := p.Exe() 79 | if err != nil { 80 | log.Err(err).Msg("获取可执行文件路径失败") 81 | return nil, err 82 | } 83 | procInfo.ExePath = exePath 84 | 85 | // 获取版本信息 86 | versionInfo, err := appver.New(exePath) 87 | if err != nil { 88 | log.Err(err).Msg("获取版本信息失败") 89 | return nil, err 90 | } 91 | procInfo.Version = versionInfo.Version 92 | procInfo.FullVersion = versionInfo.FullVersion 93 | 94 | // 初始化附加信息(数据目录、账户名) 95 | if err := initializeProcessInfo(p, procInfo); err != nil { 96 | log.Err(err).Msg("初始化进程信息失败") 97 | // 即使初始化失败也返回部分信息 98 | } 99 | 100 | return procInfo, nil 101 | } 102 | -------------------------------------------------------------------------------- /internal/wechat/process/windows/detector_others.go: -------------------------------------------------------------------------------- 1 | //go:build !windows 2 | 3 | package windows 4 | 5 | import ( 6 | "github.com/shirou/gopsutil/v4/process" 7 | "github.com/sjzar/chatlog/internal/wechat/model" 8 | ) 9 | 10 | func initializeProcessInfo(p *process.Process, info *model.Process) error { 11 | return nil 12 | } 13 | -------------------------------------------------------------------------------- /internal/wechat/process/windows/detector_windows.go: -------------------------------------------------------------------------------- 1 | package windows 2 | 3 | import ( 4 | "path/filepath" 5 | "strings" 6 | 7 | "github.com/rs/zerolog/log" 8 | "github.com/shirou/gopsutil/v4/process" 9 | 10 | "github.com/sjzar/chatlog/internal/wechat/model" 11 | ) 12 | 13 | // initializeProcessInfo 获取进程的数据目录和账户名 14 | func initializeProcessInfo(p *process.Process, info *model.Process) error { 15 | files, err := p.OpenFiles() 16 | if err != nil { 17 | log.Err(err).Msgf("获取进程 %d 的打开文件失败", p.Pid) 18 | return err 19 | } 20 | 21 | dbPath := V3DBFile 22 | if info.Version == 4 { 23 | dbPath = V4DBFile 24 | } 25 | 26 | for _, f := range files { 27 | if strings.HasSuffix(f.Path, dbPath) { 28 | filePath := f.Path[4:] // 移除 "\\?\" 前缀 29 | parts := strings.Split(filePath, string(filepath.Separator)) 30 | if len(parts) < 4 { 31 | log.Debug().Msg("无效的文件路径: " + filePath) 32 | continue 33 | } 34 | 35 | info.Status = model.StatusOnline 36 | if info.Version == 4 { 37 | info.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator)) 38 | info.AccountName = parts[len(parts)-4] 39 | } else { 40 | info.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator)) 41 | info.AccountName = parts[len(parts)-3] 42 | } 43 | return nil 44 | } 45 | } 46 | 47 | return nil 48 | } 49 | -------------------------------------------------------------------------------- /internal/wechat/wechat.go: -------------------------------------------------------------------------------- 1 | package wechat 2 | 3 | import ( 4 | "context" 5 | "os" 6 | 7 | "github.com/sjzar/chatlog/internal/errors" 8 | "github.com/sjzar/chatlog/internal/wechat/decrypt" 9 | "github.com/sjzar/chatlog/internal/wechat/key" 10 | "github.com/sjzar/chatlog/internal/wechat/model" 11 | ) 12 | 13 | // Account 表示一个微信账号 14 | type Account struct { 15 | Name string 16 | Platform string 17 | Version int 18 | FullVersion string 19 | DataDir string 20 | Key string 21 | PID uint32 22 | ExePath string 23 | Status string 24 | } 25 | 26 | // NewAccount 创建新的账号对象 27 | func NewAccount(proc *model.Process) *Account { 28 | return &Account{ 29 | Name: proc.AccountName, 30 | Platform: proc.Platform, 31 | Version: proc.Version, 32 | FullVersion: proc.FullVersion, 33 | DataDir: proc.DataDir, 34 | PID: proc.PID, 35 | ExePath: proc.ExePath, 36 | Status: proc.Status, 37 | } 38 | } 39 | 40 | // RefreshStatus 刷新账号的进程状态 41 | func (a *Account) RefreshStatus() error { 42 | // 查找所有微信进程 43 | Load() 44 | 45 | process, err := GetProcess(a.Name) 46 | if err != nil { 47 | a.Status = model.StatusOffline 48 | return nil 49 | } 50 | 51 | if process.AccountName == a.Name { 52 | // 更新进程信息 53 | a.PID = process.PID 54 | a.ExePath = process.ExePath 55 | a.Platform = process.Platform 56 | a.Version = process.Version 57 | a.FullVersion = process.FullVersion 58 | a.Status = process.Status 59 | a.DataDir = process.DataDir 60 | } 61 | 62 | return nil 63 | } 64 | 65 | // GetKey 获取账号的密钥 66 | func (a *Account) GetKey(ctx context.Context) (string, error) { 67 | // 如果已经有密钥,直接返回 68 | if a.Key != "" { 69 | return a.Key, nil 70 | } 71 | 72 | // 刷新进程状态 73 | if err := a.RefreshStatus(); err != nil { 74 | return "", errors.RefreshProcessStatusFailed(err) 75 | } 76 | 77 | // 检查账号状态 78 | if a.Status != model.StatusOnline { 79 | return "", errors.WeChatAccountNotOnline(a.Name) 80 | } 81 | 82 | // 创建密钥提取器 - 使用新的接口,传入平台和版本信息 83 | extractor, err := key.NewExtractor(a.Platform, a.Version) 84 | if err != nil { 85 | return "", err 86 | } 87 | 88 | process, err := GetProcess(a.Name) 89 | if err != nil { 90 | return "", err 91 | } 92 | 93 | validator, err := decrypt.NewValidator(process.Platform, process.Version, process.DataDir) 94 | if err != nil { 95 | return "", err 96 | } 97 | 98 | extractor.SetValidate(validator) 99 | 100 | // 提取密钥 101 | key, err := extractor.Extract(ctx, process) 102 | if err != nil { 103 | return "", err 104 | } 105 | 106 | // 保存密钥 107 | a.Key = key 108 | return key, nil 109 | } 110 | 111 | // DecryptDatabase 解密数据库 112 | func (a *Account) DecryptDatabase(ctx context.Context, dbPath, outputPath string) error { 113 | // 获取密钥 114 | hexKey, err := a.GetKey(ctx) 115 | if err != nil { 116 | return err 117 | } 118 | 119 | // 创建解密器 - 传入平台信息和版本 120 | decryptor, err := decrypt.NewDecryptor(a.Platform, a.Version) 121 | if err != nil { 122 | return err 123 | } 124 | 125 | // 创建输出文件 126 | output, err := os.Create(outputPath) 127 | if err != nil { 128 | return err 129 | } 130 | defer output.Close() 131 | 132 | // 解密数据库 133 | return decryptor.Decrypt(ctx, dbPath, hexKey, output) 134 | } 135 | -------------------------------------------------------------------------------- /internal/wechatdb/datasource/datasource.go: -------------------------------------------------------------------------------- 1 | package datasource 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/fsnotify/fsnotify" 8 | 9 | "github.com/sjzar/chatlog/internal/errors" 10 | "github.com/sjzar/chatlog/internal/model" 11 | "github.com/sjzar/chatlog/internal/wechatdb/datasource/darwinv3" 12 | v4 "github.com/sjzar/chatlog/internal/wechatdb/datasource/v4" 13 | "github.com/sjzar/chatlog/internal/wechatdb/datasource/windowsv3" 14 | ) 15 | 16 | type DataSource interface { 17 | 18 | // 消息 19 | GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) 20 | 21 | // 联系人 22 | GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) 23 | 24 | // 群聊 25 | GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) 26 | 27 | // 最近会话 28 | GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) 29 | 30 | // 媒体 31 | GetMedia(ctx context.Context, _type string, key string) (*model.Media, error) 32 | 33 | // 设置回调函数 34 | SetCallback(name string, callback func(event fsnotify.Event) error) error 35 | 36 | Close() error 37 | } 38 | 39 | func New(path string, platform string, version int) (DataSource, error) { 40 | switch { 41 | case platform == "windows" && version == 3: 42 | return windowsv3.New(path) 43 | case platform == "windows" && version == 4: 44 | return v4.New(path) 45 | case platform == "darwin" && version == 3: 46 | return darwinv3.New(path) 47 | case platform == "darwin" && version == 4: 48 | return v4.New(path) 49 | default: 50 | return nil, errors.PlatformUnsupported(platform, version) 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /internal/wechatdb/datasource/dbm/dbm.go: -------------------------------------------------------------------------------- 1 | package dbm 2 | 3 | import ( 4 | "database/sql" 5 | "runtime" 6 | "sync" 7 | "time" 8 | 9 | "github.com/fsnotify/fsnotify" 10 | _ "github.com/mattn/go-sqlite3" 11 | "github.com/rs/zerolog/log" 12 | 13 | "github.com/sjzar/chatlog/internal/errors" 14 | "github.com/sjzar/chatlog/pkg/filecopy" 15 | "github.com/sjzar/chatlog/pkg/filemonitor" 16 | ) 17 | 18 | type DBManager struct { 19 | path string 20 | fm *filemonitor.FileMonitor 21 | fgs map[string]*filemonitor.FileGroup 22 | dbs map[string]*sql.DB 23 | dbPaths map[string][]string 24 | mutex sync.RWMutex 25 | } 26 | 27 | func NewDBManager(path string) *DBManager { 28 | return &DBManager{ 29 | path: path, 30 | fm: filemonitor.NewFileMonitor(), 31 | fgs: make(map[string]*filemonitor.FileGroup), 32 | dbs: make(map[string]*sql.DB), 33 | dbPaths: make(map[string][]string), 34 | } 35 | } 36 | 37 | func (d *DBManager) AddGroup(g *Group) error { 38 | fg, err := filemonitor.NewFileGroup(g.Name, d.path, g.Pattern, g.BlackList) 39 | if err != nil { 40 | return err 41 | } 42 | fg.AddCallback(d.Callback) 43 | d.fm.AddGroup(fg) 44 | d.mutex.Lock() 45 | d.fgs[g.Name] = fg 46 | d.mutex.Unlock() 47 | return nil 48 | } 49 | 50 | func (d *DBManager) AddCallback(name string, callback func(event fsnotify.Event) error) error { 51 | d.mutex.RLock() 52 | fg, ok := d.fgs[name] 53 | d.mutex.RUnlock() 54 | if !ok { 55 | return errors.FileGroupNotFound(name) 56 | } 57 | fg.AddCallback(callback) 58 | return nil 59 | } 60 | 61 | func (d *DBManager) GetDB(name string) (*sql.DB, error) { 62 | dbPaths, err := d.GetDBPath(name) 63 | if err != nil { 64 | return nil, err 65 | } 66 | return d.OpenDB(dbPaths[0]) 67 | } 68 | 69 | func (d *DBManager) GetDBs(name string) ([]*sql.DB, error) { 70 | dbPaths, err := d.GetDBPath(name) 71 | if err != nil { 72 | return nil, err 73 | } 74 | dbs := make([]*sql.DB, 0) 75 | for _, file := range dbPaths { 76 | db, err := d.OpenDB(file) 77 | if err != nil { 78 | return nil, err 79 | } 80 | dbs = append(dbs, db) 81 | } 82 | return dbs, nil 83 | } 84 | 85 | func (d *DBManager) GetDBPath(name string) ([]string, error) { 86 | d.mutex.RLock() 87 | dbPaths, ok := d.dbPaths[name] 88 | d.mutex.RUnlock() 89 | if !ok { 90 | d.mutex.RLock() 91 | fg, ok := d.fgs[name] 92 | d.mutex.RUnlock() 93 | if !ok { 94 | return nil, errors.FileGroupNotFound(name) 95 | } 96 | list, err := fg.List() 97 | if err != nil { 98 | return nil, errors.DBFileNotFound(d.path, fg.PatternStr, err) 99 | } 100 | if len(list) == 0 { 101 | return nil, errors.DBFileNotFound(d.path, fg.PatternStr, nil) 102 | } 103 | dbPaths = list 104 | d.mutex.Lock() 105 | d.dbPaths[name] = dbPaths 106 | d.mutex.Unlock() 107 | } 108 | return dbPaths, nil 109 | } 110 | 111 | func (d *DBManager) OpenDB(path string) (*sql.DB, error) { 112 | d.mutex.RLock() 113 | db, ok := d.dbs[path] 114 | d.mutex.RUnlock() 115 | if ok { 116 | return db, nil 117 | } 118 | var err error 119 | tempPath := path 120 | if runtime.GOOS == "windows" { 121 | tempPath, err = filecopy.GetTempCopy(path) 122 | if err != nil { 123 | log.Err(err).Msgf("获取临时拷贝文件 %s 失败", path) 124 | return nil, err 125 | } 126 | } 127 | db, err = sql.Open("sqlite3", tempPath) 128 | if err != nil { 129 | log.Err(err).Msgf("连接数据库 %s 失败", path) 130 | return nil, err 131 | } 132 | d.mutex.Lock() 133 | d.dbs[path] = db 134 | d.mutex.Unlock() 135 | return db, nil 136 | } 137 | 138 | func (d *DBManager) Callback(event fsnotify.Event) error { 139 | if !event.Op.Has(fsnotify.Create) { 140 | return nil 141 | } 142 | 143 | d.mutex.Lock() 144 | db, ok := d.dbs[event.Name] 145 | if ok { 146 | delete(d.dbs, event.Name) 147 | go func(db *sql.DB) { 148 | time.Sleep(time.Second * 5) 149 | db.Close() 150 | }(db) 151 | } 152 | d.mutex.Unlock() 153 | 154 | return nil 155 | } 156 | 157 | func (d *DBManager) Start() error { 158 | return d.fm.Start() 159 | } 160 | 161 | func (d *DBManager) Stop() error { 162 | return d.fm.Stop() 163 | } 164 | 165 | func (d *DBManager) Close() error { 166 | for _, db := range d.dbs { 167 | db.Close() 168 | } 169 | return d.fm.Stop() 170 | } 171 | -------------------------------------------------------------------------------- /internal/wechatdb/datasource/dbm/dbm_test.go: -------------------------------------------------------------------------------- 1 | package dbm 2 | 3 | import ( 4 | "fmt" 5 | "testing" 6 | "time" 7 | ) 8 | 9 | func TestXxx(t *testing.T) { 10 | path := "/Users/sarv/Documents/chatlog/bigjun_9e7a" 11 | 12 | g := &Group{ 13 | Name: "session", 14 | Pattern: `session\.db
The response has been limited to 50k tokens of the smallest files in the repo. You can remove this limitation by removing the max tokens filter.
, 15 | BlackList: []string{}, 16 | } 17 | 18 | d := NewDBManager(path) 19 | d.AddGroup(g) 20 | d.Start() 21 | 22 | i := 0 23 | for { 24 | db, err := d.GetDB("session") 25 | if err != nil { 26 | fmt.Println(err) 27 | break 28 | } 29 | 30 | var username string 31 | row := db.QueryRow(`SELECT username FROM SessionTable LIMIT 1`) 32 | if err := row.Scan(&username); err != nil { 33 | fmt.Printf("Error scanning row: %v\n", err) 34 | time.Sleep(100 * time.Millisecond) 35 | continue 36 | } 37 | fmt.Printf("%d: Username: %s\n", i, username) 38 | i++ 39 | time.Sleep(1000 * time.Millisecond) 40 | } 41 | 42 | } 43 | -------------------------------------------------------------------------------- /internal/wechatdb/datasource/dbm/group.go: -------------------------------------------------------------------------------- 1 | package dbm 2 | 3 | type Group struct { 4 | Name string 5 | Pattern string 6 | BlackList []string 7 | } 8 | -------------------------------------------------------------------------------- /internal/wechatdb/repository/media.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sjzar/chatlog/internal/model" 7 | ) 8 | 9 | func (r *Repository) GetMedia(ctx context.Context, _type string, key string) (*model.Media, error) { 10 | return r.ds.GetMedia(ctx, _type, key) 11 | } 12 | -------------------------------------------------------------------------------- /internal/wechatdb/repository/message.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | "strings" 6 | "time" 7 | 8 | "github.com/sjzar/chatlog/internal/model" 9 | "github.com/sjzar/chatlog/pkg/util" 10 | 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // GetMessages 实现 Repository 接口的 GetMessages 方法 15 | func (r *Repository) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) { 16 | 17 | talker, sender = r.parseTalkerAndSender(ctx, talker, sender) 18 | messages, err := r.ds.GetMessages(ctx, startTime, endTime, talker, sender, keyword, limit, offset) 19 | if err != nil { 20 | return nil, err 21 | } 22 | 23 | // 补充消息信息 24 | if err := r.EnrichMessages(ctx, messages); err != nil { 25 | log.Debug().Msgf("EnrichMessages failed: %v", err) 26 | } 27 | 28 | return messages, nil 29 | } 30 | 31 | // EnrichMessages 补充消息的额外信息 32 | func (r *Repository) EnrichMessages(ctx context.Context, messages []*model.Message) error { 33 | for _, msg := range messages { 34 | r.enrichMessage(msg) 35 | } 36 | return nil 37 | } 38 | 39 | // enrichMessage 补充单条消息的额外信息 40 | func (r *Repository) enrichMessage(msg *model.Message) { 41 | // 处理群聊消息 42 | if msg.IsChatRoom { 43 | // 补充群聊名称 44 | if chatRoom, ok := r.chatRoomCache[msg.Talker]; ok { 45 | msg.TalkerName = chatRoom.DisplayName() 46 | 47 | // 补充发送者在群里的显示名称 48 | if displayName, ok := chatRoom.User2DisplayName[msg.Sender]; ok { 49 | msg.SenderName = displayName 50 | } 51 | } 52 | } 53 | 54 | // 如果不是自己发送的消息且还没有显示名称,尝试补充发送者信息 55 | if msg.SenderName == "" && !msg.IsSelf { 56 | contact := r.getFullContact(msg.Sender) 57 | if contact != nil { 58 | msg.SenderName = contact.DisplayName() 59 | } 60 | } 61 | } 62 | 63 | func (r *Repository) parseTalkerAndSender(ctx context.Context, talker, sender string) (string, string) { 64 | displayName2User := make(map[string]string) 65 | users := make(map[string]bool) 66 | 67 | talkers := util.Str2List(talker, ",") 68 | if len(talkers) > 0 { 69 | for i := 0; i < len(talkers); i++ { 70 | if contact, _ := r.GetContact(ctx, talkers[i]); contact != nil { 71 | talkers[i] = contact.UserName 72 | } else if chatRoom, _ := r.GetChatRoom(ctx, talker); chatRoom != nil { 73 | talkers[i] = chatRoom.Name 74 | } 75 | } 76 | // 获取群聊的用户列表 77 | for i := 0; i < len(talkers); i++ { 78 | if chatRoom, _ := r.GetChatRoom(ctx, talkers[i]); chatRoom != nil { 79 | for user, displayName := range chatRoom.User2DisplayName { 80 | displayName2User[displayName] = user 81 | } 82 | for _, user := range chatRoom.Users { 83 | users[user.UserName] = true 84 | } 85 | } 86 | } 87 | talker = strings.Join(talkers, ",") 88 | } 89 | 90 | senders := util.Str2List(sender, ",") 91 | if len(senders) > 0 { 92 | for i := 0; i < len(senders); i++ { 93 | if user, ok := displayName2User[senders[i]]; ok { 94 | senders[i] = user 95 | } else { 96 | // FIXME 大量群聊用户名称重复,无法直接通过 GetContact 获取 ID,后续再优化 97 | for user := range users { 98 | if contact := r.getFullContact(user); contact != nil { 99 | if contact.DisplayName() == senders[i] { 100 | senders[i] = user 101 | break 102 | } 103 | } 104 | } 105 | } 106 | } 107 | sender = strings.Join(senders, ",") 108 | } 109 | 110 | return talker, sender 111 | } 112 | -------------------------------------------------------------------------------- /internal/wechatdb/repository/repository.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/fsnotify/fsnotify" 7 | "github.com/rs/zerolog/log" 8 | 9 | "github.com/sjzar/chatlog/internal/errors" 10 | "github.com/sjzar/chatlog/internal/model" 11 | "github.com/sjzar/chatlog/internal/wechatdb/datasource" 12 | ) 13 | 14 | // Repository 实现了 repository.Repository 接口 15 | type Repository struct { 16 | ds datasource.DataSource 17 | 18 | // Cache for contact 19 | contactCache map[string]*model.Contact 20 | aliasToContact map[string][]*model.Contact 21 | remarkToContact map[string][]*model.Contact 22 | nickNameToContact map[string][]*model.Contact 23 | chatRoomInContact map[string]*model.Contact 24 | contactList []string 25 | aliasList []string 26 | remarkList []string 27 | nickNameList []string 28 | 29 | // Cache for chat room 30 | chatRoomCache map[string]*model.ChatRoom 31 | remarkToChatRoom map[string][]*model.ChatRoom 32 | nickNameToChatRoom map[string][]*model.ChatRoom 33 | chatRoomList []string 34 | chatRoomRemark []string 35 | chatRoomNickName []string 36 | 37 | // 快速查找索引 38 | chatRoomUserToInfo map[string]*model.Contact 39 | } 40 | 41 | // New 创建一个新的 Repository 42 | func New(ds datasource.DataSource) (*Repository, error) { 43 | r := &Repository{ 44 | ds: ds, 45 | contactCache: make(map[string]*model.Contact), 46 | aliasToContact: make(map[string][]*model.Contact), 47 | remarkToContact: make(map[string][]*model.Contact), 48 | nickNameToContact: make(map[string][]*model.Contact), 49 | chatRoomUserToInfo: make(map[string]*model.Contact), 50 | contactList: make([]string, 0), 51 | aliasList: make([]string, 0), 52 | remarkList: make([]string, 0), 53 | nickNameList: make([]string, 0), 54 | chatRoomCache: make(map[string]*model.ChatRoom), 55 | remarkToChatRoom: make(map[string][]*model.ChatRoom), 56 | nickNameToChatRoom: make(map[string][]*model.ChatRoom), 57 | chatRoomList: make([]string, 0), 58 | chatRoomRemark: make([]string, 0), 59 | chatRoomNickName: make([]string, 0), 60 | } 61 | 62 | // 初始化缓存 63 | if err := r.initCache(context.Background()); err != nil { 64 | return nil, errors.InitCacheFailed(err) 65 | } 66 | 67 | ds.SetCallback("contact", r.contactCallback) 68 | ds.SetCallback("chatroom", r.chatroomCallback) 69 | 70 | return r, nil 71 | } 72 | 73 | // initCache 初始化缓存 74 | func (r *Repository) initCache(ctx context.Context) error { 75 | // 初始化联系人缓存 76 | if err := r.initContactCache(ctx); err != nil { 77 | return err 78 | } 79 | 80 | // 初始化群聊缓存 81 | if err := r.initChatRoomCache(ctx); err != nil { 82 | return err 83 | } 84 | 85 | return nil 86 | } 87 | 88 | func (r *Repository) contactCallback(event fsnotify.Event) error { 89 | if !event.Op.Has(fsnotify.Create) { 90 | return nil 91 | } 92 | if err := r.initContactCache(context.Background()); err != nil { 93 | log.Err(err).Msgf("Failed to reinitialize contact cache: %s", event.Name) 94 | } 95 | return nil 96 | } 97 | 98 | func (r *Repository) chatroomCallback(event fsnotify.Event) error { 99 | if !event.Op.Has(fsnotify.Create) { 100 | return nil 101 | } 102 | if err := r.initChatRoomCache(context.Background()); err != nil { 103 | log.Err(err).Msgf("Failed to reinitialize contact cache: %s", event.Name) 104 | } 105 | return nil 106 | } 107 | 108 | // Close 实现 Repository 接口的 Close 方法 109 | func (r *Repository) Close() error { 110 | return r.ds.Close() 111 | } 112 | -------------------------------------------------------------------------------- /internal/wechatdb/repository/session.go: -------------------------------------------------------------------------------- 1 | package repository 2 | 3 | import ( 4 | "context" 5 | 6 | "github.com/sjzar/chatlog/internal/model" 7 | ) 8 | 9 | func (r *Repository) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) { 10 | return r.ds.GetSessions(ctx, key, limit, offset) 11 | } 12 | -------------------------------------------------------------------------------- /internal/wechatdb/wechatdb.go: -------------------------------------------------------------------------------- 1 | package wechatdb 2 | 3 | import ( 4 | "context" 5 | "time" 6 | 7 | "github.com/sjzar/chatlog/internal/model" 8 | "github.com/sjzar/chatlog/internal/wechatdb/datasource" 9 | "github.com/sjzar/chatlog/internal/wechatdb/repository" 10 | 11 | _ "github.com/mattn/go-sqlite3" 12 | ) 13 | 14 | type DB struct { 15 | path string 16 | platform string 17 | version int 18 | ds datasource.DataSource 19 | repo *repository.Repository 20 | } 21 | 22 | func New(path string, platform string, version int) (*DB, error) { 23 | 24 | w := &DB{ 25 | path: path, 26 | platform: platform, 27 | version: version, 28 | } 29 | 30 | // 初始化,加载数据库文件信息 31 | if err := w.Initialize(); err != nil { 32 | return nil, err 33 | } 34 | 35 | return w, nil 36 | } 37 | 38 | func (w *DB) Close() error { 39 | if w.repo != nil { 40 | return w.repo.Close() 41 | } 42 | return nil 43 | } 44 | 45 | func (w *DB) Initialize() error { 46 | var err error 47 | w.ds, err = datasource.New(w.path, w.platform, w.version) 48 | if err != nil { 49 | return err 50 | } 51 | 52 | w.repo, err = repository.New(w.ds) 53 | if err != nil { 54 | return err 55 | } 56 | 57 | return nil 58 | } 59 | 60 | func (w *DB) GetMessages(start, end time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) { 61 | ctx := context.Background() 62 | 63 | // 使用 repository 获取消息 64 | messages, err := w.repo.GetMessages(ctx, start, end, talker, sender, keyword, limit, offset) 65 | if err != nil { 66 | return nil, err 67 | } 68 | 69 | return messages, nil 70 | } 71 | 72 | type GetContactsResp struct { 73 | Items []*model.Contact `json:"items"` 74 | } 75 | 76 | func (w *DB) GetContacts(key string, limit, offset int) (*GetContactsResp, error) { 77 | ctx := context.Background() 78 | 79 | contacts, err := w.repo.GetContacts(ctx, key, limit, offset) 80 | if err != nil { 81 | return nil, err 82 | } 83 | 84 | return &GetContactsResp{ 85 | Items: contacts, 86 | }, nil 87 | } 88 | 89 | type GetChatRoomsResp struct { 90 | Items []*model.ChatRoom `json:"items"` 91 | } 92 | 93 | func (w *DB) GetChatRooms(key string, limit, offset int) (*GetChatRoomsResp, error) { 94 | ctx := context.Background() 95 | 96 | chatRooms, err := w.repo.GetChatRooms(ctx, key, limit, offset) 97 | if err != nil { 98 | return nil, err 99 | } 100 | 101 | return &GetChatRoomsResp{ 102 | Items: chatRooms, 103 | }, nil 104 | } 105 | 106 | type GetSessionsResp struct { 107 | Items []*model.Session `json:"items"` 108 | } 109 | 110 | func (w *DB) GetSessions(key string, limit, offset int) (*GetSessionsResp, error) { 111 | ctx := context.Background() 112 | 113 | // 使用 repository 获取会话列表 114 | sessions, err := w.repo.GetSessions(ctx, key, limit, offset) 115 | if err != nil { 116 | return nil, err 117 | } 118 | 119 | return &GetSessionsResp{ 120 | Items: sessions, 121 | }, nil 122 | } 123 | 124 | func (w *DB) GetMedia(_type string, key string) (*model.Media, error) { 125 | return w.repo.GetMedia(context.Background(), _type, key) 126 | } 127 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | 6 | "github.com/sjzar/chatlog/cmd/chatlog" 7 | ) 8 | 9 | func main() { 10 | log.SetFlags(log.LstdFlags | log.Lshortfile) 11 | chatlog.Execute() 12 | } 13 | -------------------------------------------------------------------------------- /pkg/appver/version.go: -------------------------------------------------------------------------------- 1 | package appver 2 | 3 | type Info struct { 4 | FilePath string `json:"file_path"` 5 | CompanyName string `json:"company_name"` 6 | FileDescription string `json:"file_description"` 7 | Version int `json:"version"` 8 | FullVersion string `json:"full_version"` 9 | LegalCopyright string `json:"legal_copyright"` 10 | ProductName string `json:"product_name"` 11 | ProductVersion string `json:"product_version"` 12 | } 13 | 14 | func New(filePath string) (*Info, error) { 15 | i := &Info{ 16 | FilePath: filePath, 17 | } 18 | 19 | err := i.initialize() 20 | if err != nil { 21 | return nil, err 22 | } 23 | 24 | return i, nil 25 | } 26 | -------------------------------------------------------------------------------- /pkg/appver/version_darwin.go: -------------------------------------------------------------------------------- 1 | package appver 2 | 3 | import ( 4 | "os" 5 | "path/filepath" 6 | "strconv" 7 | "strings" 8 | 9 | "howett.net/plist" 10 | ) 11 | 12 | const ( 13 | InfoFile = "Info.plist" 14 | ) 15 | 16 | type Plist struct { 17 | CFBundleShortVersionString string `plist:"CFBundleShortVersionString"` 18 | NSHumanReadableCopyright string `plist:"NSHumanReadableCopyright"` 19 | } 20 | 21 | func (i *Info) initialize() error { 22 | 23 | parts := strings.Split(i.FilePath, string(filepath.Separator)) 24 | file := filepath.Join(append(parts[:len(parts)-2], InfoFile)...) 25 | b, err := os.ReadFile("/" + file) 26 | if err != nil { 27 | return err 28 | } 29 | 30 | p := Plist{} 31 | _, err = plist.Unmarshal(b, &p) 32 | if err != nil { 33 | return err 34 | } 35 | 36 | i.FullVersion = p.CFBundleShortVersionString 37 | i.Version, _ = strconv.Atoi(strings.Split(i.FullVersion, ".")[0]) 38 | i.CompanyName = p.NSHumanReadableCopyright 39 | 40 | return nil 41 | } 42 | -------------------------------------------------------------------------------- /pkg/appver/version_others.go: -------------------------------------------------------------------------------- 1 | //go:build !windows && !darwin 2 | 3 | package appver 4 | 5 | func (i *Info) initialize() error { 6 | return nil 7 | } 8 | -------------------------------------------------------------------------------- /pkg/appver/version_windows.go: -------------------------------------------------------------------------------- 1 | package appver 2 | 3 | import ( 4 | "fmt" 5 | "syscall" 6 | "unsafe" 7 | ) 8 | 9 | var ( 10 | modversion = syscall.NewLazyDLL("version.dll") 11 | procGetFileVersionInfoSize = modversion.NewProc("GetFileVersionInfoSizeW") 12 | procGetFileVersionInfo = modversion.NewProc("GetFileVersionInfoW") 13 | procVerQueryValue = modversion.NewProc("VerQueryValueW") 14 | ) 15 | 16 | // VS_FIXEDFILEINFO 结构体 17 | type VS_FIXEDFILEINFO struct { 18 | Signature uint32 19 | StrucVersion uint32 20 | FileVersionMS uint32 21 | FileVersionLS uint32 22 | ProductVersionMS uint32 23 | ProductVersionLS uint32 24 | FileFlagsMask uint32 25 | FileFlags uint32 26 | FileOS uint32 27 | FileType uint32 28 | FileSubtype uint32 29 | FileDateMS uint32 30 | FileDateLS uint32 31 | } 32 | 33 | // initialize 初始化版本信息 34 | func (i *Info) initialize() error { 35 | // 转换路径为 UTF16 36 | pathPtr, err := syscall.UTF16PtrFromString(i.FilePath) 37 | if err != nil { 38 | return err 39 | } 40 | 41 | // 获取版本信息大小 42 | var handle uintptr 43 | size, _, err := procGetFileVersionInfoSize.Call( 44 | uintptr(unsafe.Pointer(pathPtr)), 45 | uintptr(unsafe.Pointer(&handle)), 46 | ) 47 | if size == 0 { 48 | return fmt.Errorf("GetFileVersionInfoSize failed: %v", err) 49 | } 50 | 51 | // 分配内存 52 | verInfo := make([]byte, size) 53 | ret, _, err := procGetFileVersionInfo.Call( 54 | uintptr(unsafe.Pointer(pathPtr)), 55 | 0, 56 | size, 57 | uintptr(unsafe.Pointer(&verInfo[0])), 58 | ) 59 | if ret == 0 { 60 | return fmt.Errorf("GetFileVersionInfo failed: %v", err) 61 | } 62 | 63 | // 获取固定的文件信息 64 | var fixedFileInfo *VS_FIXEDFILEINFO 65 | var uLen uint32 66 | rootPtr, _ := syscall.UTF16PtrFromString("\\") 67 | ret, _, err = procVerQueryValue.Call( 68 | uintptr(unsafe.Pointer(&verInfo[0])), 69 | uintptr(unsafe.Pointer(rootPtr)), 70 | uintptr(unsafe.Pointer(&fixedFileInfo)), 71 | uintptr(unsafe.Pointer(&uLen)), 72 | ) 73 | if ret == 0 { 74 | return fmt.Errorf("VerQueryValue failed: %v", err) 75 | } 76 | 77 | // 解析文件版本 78 | i.FullVersion = fmt.Sprintf("%d.%d.%d.%d", 79 | (fixedFileInfo.FileVersionMS>>16)&0xffff, 80 | (fixedFileInfo.FileVersionMS>>0)&0xffff, 81 | (fixedFileInfo.FileVersionLS>>16)&0xffff, 82 | (fixedFileInfo.FileVersionLS>>0)&0xffff, 83 | ) 84 | i.Version = int((fixedFileInfo.FileVersionMS >> 16) & 0xffff) 85 | 86 | i.ProductVersion = fmt.Sprintf("%d.%d.%d.%d", 87 | (fixedFileInfo.ProductVersionMS>>16)&0xffff, 88 | (fixedFileInfo.ProductVersionMS>>0)&0xffff, 89 | (fixedFileInfo.ProductVersionLS>>16)&0xffff, 90 | (fixedFileInfo.ProductVersionLS>>0)&0xffff, 91 | ) 92 | 93 | // 获取翻译信息 94 | type langAndCodePage struct { 95 | language uint16 96 | codePage uint16 97 | } 98 | 99 | var lpTranslate *langAndCodePage 100 | var cbTranslate uint32 101 | transPtr, _ := syscall.UTF16PtrFromString("\\VarFileInfo\\Translation") 102 | ret, _, _ = procVerQueryValue.Call( 103 | uintptr(unsafe.Pointer(&verInfo[0])), 104 | uintptr(unsafe.Pointer(transPtr)), 105 | uintptr(unsafe.Pointer(&lpTranslate)), 106 | uintptr(unsafe.Pointer(&cbTranslate)), 107 | ) 108 | 109 | if ret != 0 && cbTranslate > 0 { 110 | // 获取所有需要的字符串信息 111 | stringInfos := map[string]*string{ 112 | "CompanyName": &i.CompanyName, 113 | "FileDescription": &i.FileDescription, 114 | "FileVersion": &i.FullVersion, 115 | "LegalCopyright": &i.LegalCopyright, 116 | "ProductName": &i.ProductName, 117 | "ProductVersion": &i.ProductVersion, 118 | } 119 | 120 | for name, ptr := range stringInfos { 121 | subBlock := fmt.Sprintf("\\StringFileInfo\\%04x%04x\\%s", 122 | lpTranslate.language, lpTranslate.codePage, name) 123 | 124 | subBlockPtr, _ := syscall.UTF16PtrFromString(subBlock) 125 | var buffer *uint16 126 | var bufLen uint32 127 | 128 | ret, _, _ = procVerQueryValue.Call( 129 | uintptr(unsafe.Pointer(&verInfo[0])), 130 | uintptr(unsafe.Pointer(subBlockPtr)), 131 | uintptr(unsafe.Pointer(&buffer)), 132 | uintptr(unsafe.Pointer(&bufLen)), 133 | ) 134 | 135 | if ret != 0 && bufLen > 0 { 136 | *ptr = syscall.UTF16ToString((*[1 << 20]uint16)(unsafe.Pointer(buffer))[:bufLen:bufLen]) 137 | } 138 | } 139 | } 140 | 141 | return nil 142 | } 143 | -------------------------------------------------------------------------------- /pkg/config/config.go: -------------------------------------------------------------------------------- 1 | /* 2 | * Copyright (c) 2023 shenjunzheng@gmail.com 3 | * 4 | * Licensed under the Apache License, Version 2.0 (the "License"); 5 | * you may not use this file except in compliance with the License. 6 | * You may obtain a copy of the License at 7 | * 8 | * http://www.apache.org/licenses/LICENSE-2.0 9 | * 10 | * Unless required by applicable law or agreed to in writing, software 11 | * distributed under the License is distributed on an "AS IS" BASIS, 12 | * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 13 | * See the License for the specific language governing permissions and 14 | * limitations under the License. 15 | */ 16 | 17 | package config 18 | 19 | import ( 20 | "errors" 21 | "os" 22 | 23 | "github.com/rs/zerolog/log" 24 | "github.com/spf13/viper" 25 | ) 26 | 27 | const ( 28 | DefaultConfigType = "json" 29 | ) 30 | 31 | var ( 32 | // ConfigName holds the name of the configuration file. 33 | ConfigName = "" 34 | 35 | // ConfigType specifies the type/format of the configuration file. 36 | ConfigType = "" 37 | 38 | // ConfigPath denotes the path to the configuration file. 39 | ConfigPath = "" 40 | 41 | // ERROR 42 | ErrInvalidDirectory = errors.New("invalid directory path") 43 | ErrMissingConfigName = errors.New("config name not specified") 44 | ) 45 | 46 | // Init initializes the configuration settings. 47 | // It sets up the name, type, and path for the configuration file. 48 | func Init(name, _type, path string) error { 49 | if len(name) == 0 { 50 | return ErrMissingConfigName 51 | } 52 | 53 | if len(_type) == 0 { 54 | _type = DefaultConfigType 55 | } 56 | 57 | var err error 58 | if len(path) == 0 { 59 | path, err = os.UserHomeDir() 60 | if err != nil { 61 | path = os.TempDir() 62 | } 63 | path += string(os.PathSeparator) + "." + name 64 | } 65 | if err := PrepareDir(path); err != nil { 66 | return err 67 | } 68 | 69 | ConfigName = name 70 | ConfigType = _type 71 | ConfigPath = path 72 | return nil 73 | } 74 | 75 | // Load loads the configuration from the previously initialized file. 76 | // It unmarshals the configuration into the provided conf interface. 77 | func Load(conf interface{}) error { 78 | viper.SetConfigName(ConfigName) 79 | viper.SetConfigType(ConfigType) 80 | viper.AddConfigPath(ConfigPath) 81 | if err := viper.ReadInConfig(); err != nil { 82 | if err := viper.SafeWriteConfig(); err != nil { 83 | return err 84 | } 85 | } 86 | if err := viper.Unmarshal(conf); err != nil { 87 | return err 88 | } 89 | SetDefault(conf) 90 | return nil 91 | } 92 | 93 | // LoadFile loads the configuration from a specified file. 94 | // It unmarshals the configuration into the provided conf interface. 95 | func LoadFile(file string, conf interface{}) error { 96 | viper.SetConfigFile(file) 97 | if err := viper.ReadInConfig(); err != nil { 98 | return err 99 | } 100 | if err := viper.Unmarshal(conf); err != nil { 101 | return err 102 | } 103 | SetDefault(conf) 104 | return nil 105 | } 106 | 107 | // SetConfig sets a configuration key to a specified value. 108 | // It also writes the updated configuration back to the file. 109 | func SetConfig(key string, value interface{}) error { 110 | viper.Set(key, value) 111 | if err := viper.WriteConfig(); err != nil { 112 | return err 113 | } 114 | return nil 115 | } 116 | 117 | // ResetConfig resets the configuration to empty. 118 | func ResetConfig() error { 119 | viper.Reset() 120 | viper.SetConfigName(ConfigName) 121 | viper.SetConfigType(ConfigType) 122 | viper.AddConfigPath(ConfigPath) 123 | return viper.WriteConfig() 124 | } 125 | 126 | // GetConfig retrieves all configuration settings as a map. 127 | func GetConfig() map[string]interface{} { 128 | return viper.AllSettings() 129 | } 130 | 131 | // PrepareDir ensures that the specified directory path exists. 132 | // If the directory does not exist, it attempts to create it. 133 | func PrepareDir(path string) error { 134 | stat, err := os.Stat(path) 135 | if err != nil { 136 | if os.IsNotExist(err) { 137 | if err := os.MkdirAll(path, 0755); err != nil { 138 | return err 139 | } 140 | } else { 141 | return err 142 | } 143 | } else if !stat.IsDir() { 144 | log.Debug().Msgf("%s is not a directory", path) 145 | return ErrInvalidDirectory 146 | } 147 | return nil 148 | } 149 | -------------------------------------------------------------------------------- /pkg/util/lz4/lz4.go: -------------------------------------------------------------------------------- 1 | package lz4 2 | 3 | import ( 4 | "github.com/pierrec/lz4/v4" 5 | ) 6 | 7 | func Decompress(src []byte) ([]byte, error) { 8 | // FIXME: lz4 的压缩率预计不到 3,这里设置了 4 保险一点 9 | out := make([]byte, len(src)*4) 10 | 11 | n, err := lz4.UncompressBlock(src, out) 12 | if err != nil { 13 | return nil, err 14 | } 15 | return out[:n], nil 16 | } 17 | -------------------------------------------------------------------------------- /pkg/util/os.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "os" 7 | "path/filepath" 8 | "regexp" 9 | "runtime" 10 | 11 | "github.com/rs/zerolog/log" 12 | ) 13 | 14 | // FindFilesWithPatterns 在指定目录下查找匹配多个正则表达式的文件 15 | // directory: 要搜索的目录路径 16 | // patterns: 正则表达式模式列表 17 | // recursive: 是否递归搜索子目录 18 | // 返回匹配的文件路径列表和可能的错误 19 | func FindFilesWithPatterns(directory string, pattern string, recursive bool) ([]string, error) { 20 | // 编译所有正则表达式 21 | re, err := regexp.Compile(pattern) 22 | if err != nil { 23 | return nil, fmt.Errorf("无效的正则表达式 '%s': %v", pattern, err) 24 | } 25 | 26 | // 检查目录是否存在 27 | dirInfo, err := os.Stat(directory) 28 | if err != nil { 29 | return nil, fmt.Errorf("无法访问目录 '%s': %v", directory, err) 30 | } 31 | if !dirInfo.IsDir() { 32 | return nil, fmt.Errorf("'%s' 不是一个目录", directory) 33 | } 34 | 35 | // 存储匹配的文件路径 36 | var matchedFiles []string 37 | 38 | // 创建文件系统 39 | fsys := os.DirFS(directory) 40 | 41 | // 遍历文件系统 42 | err = fs.WalkDir(fsys, ".", func(path string, d fs.DirEntry, err error) error { 43 | if err != nil { 44 | return err 45 | } 46 | 47 | // 如果是目录且不递归,则跳过子目录 48 | if d.IsDir() { 49 | if !recursive && path != "." { 50 | return fs.SkipDir 51 | } 52 | return nil 53 | } 54 | 55 | // 检查文件名是否匹配任何一个正则表达式 56 | if re.MatchString(d.Name()) { 57 | // 添加完整路径到结果列表 58 | fullPath := filepath.Join(directory, path) 59 | matchedFiles = append(matchedFiles, fullPath) 60 | } 61 | 62 | return nil 63 | }) 64 | 65 | if err != nil { 66 | return nil, fmt.Errorf("遍历目录时出错: %v", err) 67 | } 68 | 69 | return matchedFiles, nil 70 | } 71 | 72 | func DefaultWorkDir(account string) string { 73 | if len(account) == 0 { 74 | switch runtime.GOOS { 75 | case "windows": 76 | return filepath.Join(os.ExpandEnv("${USERPROFILE}"), "Documents", "chatlog") 77 | case "darwin": 78 | return filepath.Join(os.ExpandEnv("${HOME}"), "Documents", "chatlog") 79 | default: 80 | return filepath.Join(os.ExpandEnv("${HOME}"), "chatlog") 81 | } 82 | } 83 | switch runtime.GOOS { 84 | case "windows": 85 | return filepath.Join(os.ExpandEnv("${USERPROFILE}"), "Documents", "chatlog", account) 86 | case "darwin": 87 | return filepath.Join(os.ExpandEnv("${HOME}"), "Documents", "chatlog", account) 88 | default: 89 | return filepath.Join(os.ExpandEnv("${HOME}"), "chatlog", account) 90 | } 91 | } 92 | 93 | func GetDirSize(dir string) string { 94 | var size int64 95 | filepath.Walk(dir, func(path string, info os.FileInfo, err error) error { 96 | if err == nil { 97 | size += info.Size() 98 | } 99 | return nil 100 | }) 101 | return ByteCountSI(size) 102 | } 103 | 104 | func ByteCountSI(b int64) string { 105 | const unit = 1000 106 | if b < unit { 107 | return fmt.Sprintf("%d B", b) 108 | } 109 | div, exp := int64(unit), 0 110 | for n := b / unit; n >= unit; n /= unit { 111 | div *= unit 112 | exp++ 113 | } 114 | return fmt.Sprintf("%.1f %cB", 115 | float64(b)/float64(div), "kMGTPE"[exp]) 116 | } 117 | 118 | // PrepareDir ensures that the specified directory path exists. 119 | // If the directory does not exist, it attempts to create it. 120 | func PrepareDir(path string) error { 121 | stat, err := os.Stat(path) 122 | if err != nil { 123 | if os.IsNotExist(err) { 124 | if err := os.MkdirAll(path, 0755); err != nil { 125 | return err 126 | } 127 | } else { 128 | return err 129 | } 130 | } else if !stat.IsDir() { 131 | log.Debug().Msgf("%s is not a directory", path) 132 | return fmt.Errorf("%s is not a directory", path) 133 | } 134 | return nil 135 | } 136 | -------------------------------------------------------------------------------- /pkg/util/os_windows.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | 6 | "golang.org/x/sys/windows" 7 | ) 8 | 9 | func Is64Bit(handle windows.Handle) (bool, error) { 10 | var is32Bit bool 11 | if err := windows.IsWow64Process(handle, &is32Bit); err != nil { 12 | return false, fmt.Errorf("检查进程位数失败: %w", err) 13 | } 14 | return !is32Bit, nil 15 | } 16 | -------------------------------------------------------------------------------- /pkg/util/silk/silk.go: -------------------------------------------------------------------------------- 1 | package silk 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/sjzar/go-lame" 7 | "github.com/sjzar/go-silk" 8 | ) 9 | 10 | func Silk2MP3(data []byte) ([]byte, error) { 11 | 12 | sd := silk.SilkInit() 13 | defer sd.Close() 14 | 15 | pcmdata := sd.Decode(data) 16 | if len(pcmdata) == 0 { 17 | return nil, fmt.Errorf("silk decode failed") 18 | } 19 | 20 | le := lame.Init() 21 | defer le.Close() 22 | 23 | le.SetInSamplerate(24000) 24 | le.SetOutSamplerate(24000) 25 | le.SetNumChannels(1) 26 | le.SetBitrate(16) 27 | // IMPORTANT! 28 | le.InitParams() 29 | 30 | mp3data := le.Encode(pcmdata) 31 | if len(mp3data) == 0 { 32 | return nil, fmt.Errorf("mp3 encode failed") 33 | } 34 | 35 | return mp3data, nil 36 | } 37 | -------------------------------------------------------------------------------- /pkg/util/strings.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "fmt" 5 | "strconv" 6 | "strings" 7 | "unicode" 8 | "unicode/utf8" 9 | ) 10 | 11 | func IsNormalString(b []byte) bool { 12 | str := string(b) 13 | 14 | // 检查是否为有效的 UTF-8 15 | if !utf8.ValidString(str) { 16 | return false 17 | } 18 | 19 | // 检查是否全部为可打印字符 20 | for _, r := range str { 21 | if !unicode.IsPrint(r) { 22 | return false 23 | } 24 | } 25 | 26 | return true 27 | } 28 | 29 | func MustAnyToInt(v interface{}) int { 30 | str := fmt.Sprintf("%v", v) 31 | if i, err := strconv.Atoi(str); err == nil { 32 | return i 33 | } 34 | return 0 35 | } 36 | 37 | func IsNumeric(s string) bool { 38 | for _, r := range s { 39 | if !unicode.IsDigit(r) { 40 | return false 41 | } 42 | } 43 | return len(s) > 0 44 | } 45 | 46 | func SplitInt64ToTwoInt32(input int64) (int64, int64) { 47 | return input & 0xFFFFFFFF, input >> 32 48 | } 49 | 50 | func Str2List(str string, sep string) []string { 51 | list := make([]string, 0) 52 | 53 | if str == "" { 54 | return list 55 | } 56 | 57 | listMap := make(map[string]bool) 58 | for _, elem := range strings.Split(str, sep) { 59 | elem = strings.TrimSpace(elem) 60 | if len(elem) == 0 { 61 | continue 62 | } 63 | if _, ok := listMap[elem]; ok { 64 | continue 65 | } 66 | listMap[elem] = true 67 | list = append(list, elem) 68 | } 69 | 70 | return list 71 | } 72 | -------------------------------------------------------------------------------- /pkg/util/zstd/zstd.go: -------------------------------------------------------------------------------- 1 | package zstd 2 | 3 | import ( 4 | "github.com/klauspost/compress/zstd" 5 | ) 6 | 7 | var decoder, _ = zstd.NewReader(nil, zstd.WithDecoderConcurrency(0)) 8 | 9 | func Decompress(src []byte) ([]byte, error) { 10 | return decoder.DecodeAll(src, nil) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/version/version.go: -------------------------------------------------------------------------------- 1 | package version 2 | 3 | import ( 4 | "fmt" 5 | "runtime" 6 | "runtime/debug" 7 | "strings" 8 | ) 9 | 10 | var ( 11 | Version = "(dev)" 12 | buildInfo = debug.BuildInfo{} 13 | ) 14 | 15 | func init() { 16 | if bi, ok := debug.ReadBuildInfo(); ok { 17 | buildInfo = *bi 18 | if len(bi.Main.Version) > 0 { 19 | Version = bi.Main.Version 20 | } 21 | } 22 | } 23 | 24 | func GetMore(mod bool) string { 25 | if mod { 26 | mod := buildInfo.String() 27 | if len(mod) > 0 { 28 | return fmt.Sprintf("\t%s\n", strings.ReplaceAll(mod[:len(mod)-1], "\n", "\n\t")) 29 | } 30 | } 31 | return fmt.Sprintf("version %s %s %s/%s\n", Version, runtime.Version(), runtime.GOOS, runtime.GOARCH) 32 | } 33 | -------------------------------------------------------------------------------- /script/package.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | set -eu 4 | set -o pipefail 5 | [ "$#" = "1" ] && [ "$1" = '-v' ] && set -x 6 | 7 | OUTPUT_DIR="bin" 8 | PACKAGES_DIR="packages" 9 | TEMP_DIR="temp_package" 10 | VERSION=$(git describe --tags --always --dirty="-dev") 11 | CHECKSUMS_FILE="$PACKAGES_DIR/checksums.txt" 12 | 13 | make -f Makefile crossbuild 14 | 15 | rm -rf $PACKAGES_DIR $TEMP_DIR 16 | 17 | mkdir -p $PACKAGES_DIR $TEMP_DIR 18 | 19 | echo "" > $CHECKSUMS_FILE 20 | 21 | for binary in $OUTPUT_DIR/chatlog_*_*; do 22 | binary_name=$(basename $binary) 23 | 24 | # quick start 25 | if [[ $binary_name == "chatlog_darwin_amd64" ]]; then 26 | cp "$binary" "$PACKAGES_DIR/chatlog_macos" 27 | echo "$(sha256sum $PACKAGES_DIR/chatlog_macos | sed "s|$PACKAGES_DIR/||")" >> $CHECKSUMS_FILE 28 | elif [[ $binary_name == "chatlog_windows_amd64" ]]; then 29 | cp "$binary" "$PACKAGES_DIR/chatlog_windows.exe" 30 | echo "$(sha256sum $PACKAGES_DIR/chatlog_windows.exe | sed "s|$PACKAGES_DIR/||")" >> $CHECKSUMS_FILE 31 | elif [[ $binary_name == "chatlog_linux_amd64" ]]; then 32 | cp "$binary" "$PACKAGES_DIR/chatlog_linux" 33 | echo "$(sha256sum $PACKAGES_DIR/chatlog_linux | sed "s|$PACKAGES_DIR/||")" >> $CHECKSUMS_FILE 34 | fi 35 | 36 | cp "README.md" "LICENSE" $TEMP_DIR 37 | 38 | package_name="" 39 | os_arch=$(echo $binary_name | cut -d'_' -f 2-) 40 | if [[ $binary_name == *"_windows_"* ]]; then 41 | cp "$binary" "$TEMP_DIR/chatlog.exe" 42 | package_name="chatlog_${VERSION}_${os_arch}.zip" 43 | zip -j "$PACKAGES_DIR/$package_name" -r $TEMP_DIR/* 44 | else 45 | cp "$binary" "$TEMP_DIR/chatlog" 46 | package_name="chatlog_${VERSION}_${os_arch}.tar.gz" 47 | tar -czf "$PACKAGES_DIR/$package_name" -C $TEMP_DIR . 48 | fi 49 | 50 | rm -rf $TEMP_DIR/* 51 | 52 | if [[ ! -z "$package_name" ]]; then 53 | echo "$(sha256sum $PACKAGES_DIR/$package_name | sed "s|$PACKAGES_DIR/||")" >> $CHECKSUMS_FILE 54 | fi 55 | 56 | done 57 | 58 | rm -rf $TEMP_DIR 59 | 60 | echo "📦 All packages and their sha256 checksums have been created in $PACKAGES_DIR/" --------------------------------------------------------------------------------