├── .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 | 
42 |
43 | 1. 在 URL 中填写 `http://127.0.0.1:5030/sse`,并勾选 `自动执行工具`,点击 `查看工具` 即可检查连接 `chatlog` 是否正常
44 |
45 | 
46 |
47 | 3. 返回主页,选择支持 MCP 调用的模型,打开 `chatlog` 工具选项
48 |
49 | 
50 |
51 | 4. 测试功能是否正常
52 |
53 | 
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 | 
63 |
64 | 2. 选择支持 MCP 调用的模型,打开 `chatlog` 工具选项
65 |
66 | 
67 |
68 | 3. 测试功能是否正常
69 |
70 | 
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 | 
101 |
102 | 5. 测试功能是否正常
103 |
104 | 
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 | 
147 |
148 | 4. 测试功能是否正常
149 |
150 | 
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
, []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
, []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+(.*)
)
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]+)?
)
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
,
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/"
--------------------------------------------------------------------------------