Compare commits
19 Commits
v0.0.1
...
feature/me
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
977194b5cf | ||
|
|
a745519451 | ||
|
|
85b5465d2a | ||
|
|
b866d6eddd | ||
|
|
871ad50b3b | ||
|
|
b64a3a1caa | ||
|
|
25d0b394e2 | ||
|
|
f2aa923e99 | ||
|
|
ba3563ad4e | ||
|
|
4983d27054 | ||
|
|
b64902ecb6 | ||
|
|
dc116c50bf | ||
|
|
b4378a63a3 | ||
|
|
c12ee8bfce | ||
|
|
167a9ca873 | ||
|
|
f31953c42b | ||
|
|
98f41454fb | ||
|
|
3f673cbd7e | ||
|
|
80c7e67106 |
17
.github/workflows/release.yml
vendored
17
.github/workflows/release.yml
vendored
@@ -12,11 +12,14 @@ jobs:
|
|||||||
release:
|
release:
|
||||||
name: Release Binary
|
name: Release Binary
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
|
container:
|
||||||
|
image: goreleaser/goreleaser-cross:v1.24
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
fetch-depth: 0
|
fetch-depth: 0
|
||||||
|
- run: git config --global --add safe.directory "$(pwd)"
|
||||||
|
|
||||||
- name: Setup Go
|
- name: Setup Go
|
||||||
uses: actions/setup-go@v4
|
uses: actions/setup-go@v4
|
||||||
@@ -36,16 +39,8 @@ jobs:
|
|||||||
with:
|
with:
|
||||||
install-only: true
|
install-only: true
|
||||||
|
|
||||||
- name: Build Package
|
- name: Run GoReleaser
|
||||||
run: |
|
run: goreleaser release --clean
|
||||||
./script/package.sh
|
|
||||||
|
|
||||||
- name: Release
|
|
||||||
uses: softprops/action-gh-release@v1
|
|
||||||
if: startsWith(github.ref, 'refs/tags/')
|
|
||||||
env:
|
env:
|
||||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||||
with:
|
ENABLE_UPX: true
|
||||||
files: packages/*
|
|
||||||
draft: true
|
|
||||||
prerelease: true
|
|
||||||
|
|||||||
4
.gitignore
vendored
4
.gitignore
vendored
@@ -27,5 +27,5 @@ go.work.sum
|
|||||||
# syncthing files
|
# syncthing files
|
||||||
.stfolder
|
.stfolder
|
||||||
|
|
||||||
chatlog
|
chatlog.exe# Added by goreleaser init:
|
||||||
chatlog.exe
|
dist/
|
||||||
|
|||||||
90
.goreleaser.yaml
Normal file
90
.goreleaser.yaml
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
# GoReleaser v2 配置
|
||||||
|
version: 2
|
||||||
|
|
||||||
|
before:
|
||||||
|
hooks:
|
||||||
|
- go mod tidy
|
||||||
|
|
||||||
|
builds:
|
||||||
|
- id: darwin-amd64
|
||||||
|
binary: chatlog
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=o64-clang
|
||||||
|
- CXX=o64-clang++
|
||||||
|
goos:
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
|
||||||
|
|
||||||
|
- id: darwin-arm64
|
||||||
|
binary: chatlog
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=oa64-clang
|
||||||
|
- CXX=oa64-clang++
|
||||||
|
goos:
|
||||||
|
- darwin
|
||||||
|
goarch:
|
||||||
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
|
||||||
|
|
||||||
|
- id: windows-amd64
|
||||||
|
binary: chatlog
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=x86_64-w64-mingw32-gcc
|
||||||
|
- CXX=x86_64-w64-mingw32-g++
|
||||||
|
goos:
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- amd64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
|
||||||
|
|
||||||
|
- id: windows-arm64
|
||||||
|
binary: chatlog
|
||||||
|
env:
|
||||||
|
- CGO_ENABLED=1
|
||||||
|
- CC=/llvm-mingw/bin/aarch64-w64-mingw32-gcc
|
||||||
|
- CXX=/llvm-mingw/bin/aarch64-w64-mingw32-g++
|
||||||
|
goos:
|
||||||
|
- windows
|
||||||
|
goarch:
|
||||||
|
- arm64
|
||||||
|
ldflags:
|
||||||
|
- -s -w -X github.com/sjzar/chatlog/pkg/version.Version={{.Version}}
|
||||||
|
|
||||||
|
archives:
|
||||||
|
- id: default
|
||||||
|
format: tar.gz
|
||||||
|
name_template: >-
|
||||||
|
{{ .ProjectName }}_
|
||||||
|
{{- .Version }}_
|
||||||
|
{{- .Os }}_
|
||||||
|
{{- .Arch }}
|
||||||
|
format_overrides:
|
||||||
|
- goos: windows
|
||||||
|
format: zip
|
||||||
|
files:
|
||||||
|
- LICENSE
|
||||||
|
- README.md
|
||||||
|
|
||||||
|
upx:
|
||||||
|
- enabled: "{{ .Env.ENABLE_UPX }}"
|
||||||
|
goos: [darwin, windows]
|
||||||
|
goarch: [amd64]
|
||||||
|
compress: best
|
||||||
|
|
||||||
|
checksum:
|
||||||
|
name_template: 'checksums.txt'
|
||||||
|
algorithm: sha256
|
||||||
|
|
||||||
|
# 配置 GitHub Release
|
||||||
|
release:
|
||||||
|
draft: true
|
||||||
|
prerelease: auto
|
||||||
|
mode: replace
|
||||||
121
DISCLAIMER.md
Normal file
121
DISCLAIMER.md
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
# Chatlog 免责声明
|
||||||
|
|
||||||
|
## 1. 定义
|
||||||
|
|
||||||
|
在本免责声明中,除非上下文另有说明,下列术语应具有以下含义:
|
||||||
|
|
||||||
|
- **"本项目"或"Chatlog"**:指本开源软件项目,包括其源代码、可执行程序、文档及相关资源。
|
||||||
|
- **"开发者"**:指本项目的创建者、维护者及代码贡献者。
|
||||||
|
- **"用户"**:指下载、安装、使用或以任何方式接触本项目的个人或实体。
|
||||||
|
- **"聊天数据"**:指通过各类即时通讯软件生成的对话内容及相关元数据。
|
||||||
|
- **"合法授权"**:指根据适用法律法规,由数据所有者或数据主体明确授予的处理其聊天数据的权限。
|
||||||
|
- **"第三方服务"**:指由非本项目开发者提供的外部服务,如大型语言模型(LLM) API 服务。
|
||||||
|
|
||||||
|
## 2. 使用目的与法律遵守
|
||||||
|
|
||||||
|
本项目仅供学习、研究和个人合法使用。用户须严格遵守所在国家/地区的法律法规使用本工具。任何违反法律法规、侵犯他人合法权益的行为,均与本项目及其开发者无关,相关法律责任由用户自行承担。
|
||||||
|
|
||||||
|
⚠️ **用户应自行了解并遵守当地有关数据访问、隐私保护、计算机安全和网络安全的法律法规。不同司法管辖区对数据处理有不同的法律要求,用户有责任确保其使用行为符合所有适用法规。**
|
||||||
|
|
||||||
|
## 3. 授权范围与隐私保护
|
||||||
|
|
||||||
|
- 本工具仅限于处理用户自己合法拥有的聊天数据,或已获得数据所有者明确授权的数据。
|
||||||
|
- 严禁将本工具用于未经授权获取、查看或分析他人聊天记录,或侵犯他人隐私权。
|
||||||
|
- 用户应采取适当措施保护通过本工具获取和处理的聊天数据安全,包括但不限于加密存储、限制访问权限、定期删除不必要数据等。
|
||||||
|
- 用户应确保其处理的聊天数据符合相关数据保护法规,包括但不限于获得必要的同意、保障数据主体权利、遵守数据最小化原则等。
|
||||||
|
|
||||||
|
## 4. 使用限制
|
||||||
|
|
||||||
|
- 本项目仅允许在合法授权情况下对聊天数据库进行备份与查看。
|
||||||
|
- 未经明确授权,严禁将本项目用于访问、查看、分析或处理任何第三方聊天数据。
|
||||||
|
- 使用第三方 LLM 服务时,用户应遵守相关服务提供商的服务条款和使用政策。
|
||||||
|
- 用户不得规避本项目中的任何技术限制,或尝试反向工程、反编译或反汇编本项目,除非适用法律明确允许此类活动。
|
||||||
|
|
||||||
|
## 5. 技术风险声明
|
||||||
|
|
||||||
|
⚠️ **使用本项目存在以下技术风险,用户应充分了解并自行承担:**
|
||||||
|
|
||||||
|
- 本工具需要访问聊天软件的数据库文件,可能因聊天软件版本更新导致功能失效或数据不兼容。
|
||||||
|
- 在 macOS 系统上使用时,需要临时关闭 SIP 安全机制,这可能降低系统安全性,用户应了解相关风险并自行决定是否使用。
|
||||||
|
- 本项目可能存在未知的技术缺陷或安全漏洞,可能导致数据损坏、丢失或泄露。
|
||||||
|
- 使用本项目处理大量数据可能导致系统性能下降或资源占用过高。
|
||||||
|
- 第三方依赖库或 API 的变更可能影响本项目的功能或安全性。
|
||||||
|
|
||||||
|
## 6. 禁止非法用途
|
||||||
|
|
||||||
|
严禁将本项目用于以下用途:
|
||||||
|
|
||||||
|
- 从事任何形式的非法活动,包括但不限于未授权系统测试、网络渗透或其他违反法律法规的行为。
|
||||||
|
- 监控、窃取或未经授权获取他人聊天记录或个人信息。
|
||||||
|
- 将获取的数据用于骚扰、诈骗、敲诈、威胁或其他侵害他人合法权益的行为。
|
||||||
|
- 规避任何安全措施或访问控制机制。
|
||||||
|
- 传播虚假信息、仇恨言论或违反公序良俗的内容。
|
||||||
|
- 侵犯任何第三方的知识产权、隐私权或其他合法权益。
|
||||||
|
|
||||||
|
**违反上述规定的,用户应自行承担全部法律责任,并赔偿因此给开发者或第三方造成的全部损失。**
|
||||||
|
|
||||||
|
## 7. 第三方服务集成
|
||||||
|
|
||||||
|
- 用户将聊天数据与第三方 LLM 服务(如 OpenAI、Claude 等)结合使用时,应仔细阅读并遵守这些服务的使用条款、隐私政策和数据处理协议。
|
||||||
|
- 用户应了解,向第三方服务传输数据可能导致数据离开用户控制范围,并受第三方服务条款约束。
|
||||||
|
- 本项目开发者不对第三方服务的可用性、安全性、准确性或数据处理行为负责,用户应自行评估相关风险。
|
||||||
|
- 用户应确保其向第三方服务传输数据的行为符合适用的数据保护法规和第三方服务条款。
|
||||||
|
|
||||||
|
## 8. 责任限制
|
||||||
|
|
||||||
|
**在法律允许的最大范围内:**
|
||||||
|
|
||||||
|
- 本项目按"原样"和"可用"状态提供,不对功能的适用性、可靠性、准确性、完整性或及时性做任何明示或暗示的保证。
|
||||||
|
- 开发者明确否认对适销性、特定用途适用性、不侵权以及任何其他明示或暗示的保证。
|
||||||
|
- 本项目开发者和贡献者不对用户使用本工具的行为及后果承担任何法律责任。
|
||||||
|
- 对于因使用本工具而可能导致的任何直接、间接、附带、特殊、惩罚性或后果性损失,包括但不限于数据丢失、业务中断、隐私泄露、声誉损害、利润损失、法律纠纷等,本项目开发者概不负责,即使开发者已被告知此类损失的可能性。
|
||||||
|
- 在任何情况下,开发者对用户的全部责任累计不超过用户为获取本软件实际支付的金额(如为免费获取则为零)。
|
||||||
|
|
||||||
|
## 9. 知识产权声明
|
||||||
|
|
||||||
|
- 本项目基于 Apache-2.0 许可证开源,用户在使用、修改和分发时应严格遵守该许可证的所有条款。
|
||||||
|
- 本项目的名称"Chatlog"、相关标识及商标权(如有)归开发者所有,未经明确授权,用户不得以任何方式使用这些标识进行商业活动。
|
||||||
|
- 根据 Apache-2.0 许可证,用户可自由使用、修改和分发本项目代码,但须遵守许可证规定的归属声明等要求。
|
||||||
|
- 用户对其修改版本自行承担全部责任,且不得以原项目名义发布,必须明确标明其为修改版本并与原项目区分。
|
||||||
|
- 用户不得移除或更改本项目中的版权声明、商标或其他所有权声明。
|
||||||
|
|
||||||
|
## 10. 数据处理合规性
|
||||||
|
|
||||||
|
- 用户在使用本项目处理个人数据时,应遵守适用的数据保护法规,包括但不限于《中华人民共和国个人信息保护法》、《通用数据保护条例》(GDPR)等。
|
||||||
|
- 用户应确保其具有处理相关数据的合法依据,如获得数据主体的明确同意。
|
||||||
|
- 用户应实施适当的技术和组织措施,确保数据安全,防止未授权访问、意外丢失或泄露。
|
||||||
|
- 在跨境传输数据时,用户应确保符合相关法律对数据出境的要求。
|
||||||
|
- 用户应尊重数据主体权利,包括访问权、更正权、删除权等。
|
||||||
|
|
||||||
|
## 11. 免责声明接受
|
||||||
|
|
||||||
|
下载、安装、使用本项目,表示用户已阅读、理解并同意遵守本免责声明的所有条款。如不同意,请立即停止使用本工具并删除相关代码和程序。
|
||||||
|
|
||||||
|
**用户确认:**
|
||||||
|
- 已完整阅读并理解本免责声明的全部内容
|
||||||
|
- 自愿接受本免责声明的全部条款
|
||||||
|
- 具有完全民事行为能力,能够理解并承担使用本项目的风险和责任
|
||||||
|
- 将遵守本免责声明中规定的所有义务和限制
|
||||||
|
|
||||||
|
## 12. 免责声明修改与通知
|
||||||
|
|
||||||
|
- 本免责声明可能根据项目发展和法律法规变化进行修改和调整,修改后的声明将在项目官方仓库页面公布。
|
||||||
|
- 开发者没有义务个别通知用户免责声明的变更,用户应定期查阅最新版本。
|
||||||
|
- 重大变更将通过项目仓库的 Release Notes 或 README 文件更新进行通知。
|
||||||
|
- 在免责声明更新后继续使用本项目,即视为接受修改后的条款。
|
||||||
|
|
||||||
|
## 13. 法律适用与管辖
|
||||||
|
|
||||||
|
- 本免责声明受中华人民共和国法律管辖,并按其解释。
|
||||||
|
- 任何与本免责声明有关的争议,应首先通过友好协商解决;协商不成的,提交至本项目开发者所在地有管辖权的人民法院诉讼解决。
|
||||||
|
- 对于中国境外用户,如本免责声明与用户所在地强制性法律规定冲突,应以不违反该强制性规定的方式解释和适用本声明,但本声明的其余部分仍然有效。
|
||||||
|
|
||||||
|
## 14. 可分割性
|
||||||
|
|
||||||
|
如本免责声明中的任何条款被有管辖权的法院或其他权威机构认定为无效、不合法或不可执行,不影响其余条款的有效性和可执行性。无效条款应被视为从本声明中分割,并在法律允许的最大范围内由最接近原条款意图的有效条款替代。
|
||||||
|
|
||||||
|
## 15. 完整协议
|
||||||
|
|
||||||
|
本免责声明构成用户与开发者之间关于本项目使用的完整协议,取代先前或同时期关于本项目的所有口头或书面协议、提议和陈述。本声明的任何豁免、修改或补充均应以书面形式作出并经开发者签署方为有效。
|
||||||
|
|
||||||
|
|
||||||
7
Makefile
7
Makefile
@@ -6,10 +6,13 @@ endif
|
|||||||
LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)" -w -s'
|
LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)" -w -s'
|
||||||
|
|
||||||
PLATFORMS := \
|
PLATFORMS := \
|
||||||
|
darwin/amd64 \
|
||||||
|
darwin/arm64 \
|
||||||
windows/amd64 \
|
windows/amd64 \
|
||||||
windows/arm64
|
windows/arm64
|
||||||
|
|
||||||
UPX_PLATFORMS := \
|
UPX_PLATFORMS := \
|
||||||
|
darwin/amd64 \
|
||||||
windows/386 \
|
windows/386 \
|
||||||
windows/amd64
|
windows/amd64
|
||||||
|
|
||||||
@@ -35,7 +38,7 @@ test:
|
|||||||
|
|
||||||
build:
|
build:
|
||||||
@echo "🔨 Building for current platform..."
|
@echo "🔨 Building for current platform..."
|
||||||
$(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME) main.go
|
CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME) main.go
|
||||||
|
|
||||||
crossbuild: clean
|
crossbuild: clean
|
||||||
@echo "🌍 Building for multiple platforms..."
|
@echo "🌍 Building for multiple platforms..."
|
||||||
@@ -47,7 +50,7 @@ crossbuild: clean
|
|||||||
[ "$$float" != "" ] && output_name=$$output_name_$$float; \
|
[ "$$float" != "" ] && output_name=$$output_name_$$float; \
|
||||||
echo "🔨 Building for $$os/$$arch..."; \
|
echo "🔨 Building for $$os/$$arch..."; \
|
||||||
echo "🔨 Building for $$output_name..."; \
|
echo "🔨 Building for $$output_name..."; \
|
||||||
GOOS=$$os GOARCH=$$arch GOARM=$$float $(GO) build -trimpath $(LDFLAGS) -o $$output_name main.go ; \
|
GOOS=$$os GOARCH=$$arch CGO_ENABLED=1 GOARM=$$float $(GO) build -trimpath $(LDFLAGS) -o $$output_name main.go ; \
|
||||||
if [ "$(ENABLE_UPX)" = "1" ] && echo "$(UPX_PLATFORMS)" | grep -q "$$os/$$arch"; then \
|
if [ "$(ENABLE_UPX)" = "1" ] && echo "$(UPX_PLATFORMS)" | grep -q "$$os/$$arch"; then \
|
||||||
echo "⚙️ Compressing binary $$output_name..." && upx --best $$output_name; \
|
echo "⚙️ Compressing binary $$output_name..." && upx --best $$output_name; \
|
||||||
fi; \
|
fi; \
|
||||||
|
|||||||
248
README.md
248
README.md
@@ -1,17 +1,57 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
# Chatlog
|
# Chatlog
|
||||||
|
|
||||||
Chatlog 是一个聊天记录收集、分析的开源工具,旨在帮助用户更好地利用自己的聊天数据。
|

|
||||||
目前支持微信聊天记录的解密和查询,提供 Terminal UI 界面和 HTTP API 服务,让您可以方便地访问和分析聊天数据。
|
|
||||||
|
|
||||||
## 功能特点
|
_聊天记录工具,帮助大家轻松使用自己的聊天数据_
|
||||||
|
|
||||||
- **数据收集**:从本地数据库文件中获取聊天数据
|
[](https://goreportcard.com/report/github.com/sjzar/chatlog)
|
||||||
- **终端界面**:提供简洁的 Terminal UI,方便直接操作
|
[](https://godoc.org/github.com/sjzar/chatlog)
|
||||||
- **HTTP API**:提供 API 接口,支持查询聊天记录、联系人和群聊信息
|
[](https://github.com/sjzar/chatlog/releases)
|
||||||
- **MCP 支持**:实现 Model Context Protocol,可与支持 MCP 的 AI 助手无缝集成
|
[](https://github.com/sjzar/chatlog/blob/main/LICENSE)
|
||||||
- **多格式输出**:支持 JSON、CSV、纯文本等多种输出格式
|
|
||||||
|
|
||||||
## 安装
|
</div>
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Feature
|
||||||
|
|
||||||
|
- 从本地数据库文件获取聊天数据
|
||||||
|
- 支持 Windows / macOS 系统
|
||||||
|
- 支持微信 3.x / 4.0 版本
|
||||||
|
- 提供 Terminal UI 界面 & 命令行工具
|
||||||
|
- 提供 HTTP API 服务,支持查询聊天记录、联系人、群聊、最近会话等信息
|
||||||
|
- 支持 MCP SSE 协议,可与支持 MCP 的 AI 助手无缝集成
|
||||||
|
- 支持多媒体消息,支持解密图片、语音
|
||||||
|
- 支持自动解密数据,简化使用流程
|
||||||
|
- 支持多账号管理,可在不同账号间切换
|
||||||
|
|
||||||
|
|
||||||
|
## TODO
|
||||||
|
|
||||||
|
- 聊天数据全文索引
|
||||||
|
- 聊天数据统计 & Dashboard
|
||||||
|
|
||||||
|
## Quick Start
|
||||||
|
|
||||||
|
### 基本步骤
|
||||||
|
|
||||||
|
1. **安装 Chatlog**:[下载预编译版本](#下载预编译版本) 或 [使用 Go 安装](#从源码安装)
|
||||||
|
2. **运行程序**:执行 `chatlog` 启动 Terminal UI 界面
|
||||||
|
3. **解密数据**:选择 `解密数据` 菜单项
|
||||||
|
4. **开启 HTTP 服务**:选择 `开启 HTTP 服务` 菜单项
|
||||||
|
5. **访问数据**:通过 [HTTP API](#http-api) 或 [MCP 集成](#mcp-集成) 访问聊天记录
|
||||||
|
|
||||||
|
> 💡 **提示**:如果电脑端微信聊天记录不全,可以[从手机端迁移数据](#从手机迁移聊天记录)
|
||||||
|
|
||||||
|
### 常见问题快速解决
|
||||||
|
|
||||||
|
- **macOS 用户**:获取密钥前需[临时关闭 SIP](#macos-版本说明)
|
||||||
|
- **Windows 用户**:遇到界面显示问题请[使用 Windows Terminal](#windows-版本说明)
|
||||||
|
- **集成 AI 助手**:查看 [MCP 集成指南](#mcp-集成)
|
||||||
|
|
||||||
|
## 安装指南
|
||||||
|
|
||||||
### 从源码安装
|
### 从源码安装
|
||||||
|
|
||||||
@@ -23,118 +63,176 @@ go install github.com/sjzar/chatlog@latest
|
|||||||
|
|
||||||
访问 [Releases](https://github.com/sjzar/chatlog/releases) 页面下载适合您系统的预编译版本。
|
访问 [Releases](https://github.com/sjzar/chatlog/releases) 页面下载适合您系统的预编译版本。
|
||||||
|
|
||||||
## 快速开始
|
## 使用指南
|
||||||
|
|
||||||
### 终端 UI 模式
|
### Terminal UI 模式
|
||||||
|
|
||||||
1. 启动程序:
|
最简单的使用方式是通过 Terminal UI 界面操作:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./chatlog
|
chatlog
|
||||||
```
|
```
|
||||||
|
|
||||||
2. 使用界面操作:
|
操作方法:
|
||||||
- 使用方向键导航菜单
|
- 使用 `↑` `↓` 键选择菜单项
|
||||||
- 按 Enter 选择菜单项
|
- 按 `Enter` 确认选择
|
||||||
- 按 Esc 返回上一级菜单
|
- 按 `Esc` 返回上级菜单
|
||||||
- 按 Ctrl+C 退出程序
|
- 按 `Ctrl+C` 退出程序
|
||||||
|
|
||||||
### 命令行模式
|
### 命令行模式
|
||||||
|
|
||||||
获取微信进程密钥:
|
对于熟悉命令行的用户,可以直接使用以下命令:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
./chatlog key
|
# 获取微信数据密钥
|
||||||
|
chatlog key
|
||||||
|
|
||||||
|
# 解密数据库文件
|
||||||
|
chatlog decrypt
|
||||||
|
|
||||||
|
# 启动 HTTP 服务
|
||||||
|
chatlog server
|
||||||
```
|
```
|
||||||
|
|
||||||
解密数据库文件:
|
### 从手机迁移聊天记录
|
||||||
|
|
||||||
```bash
|
如果电脑端微信聊天记录不全,可以从手机端迁移数据:
|
||||||
./chatlog decrypt --data-dir "微信数据目录" --work-dir "输出目录" --key "密钥" --version 3
|
|
||||||
```
|
1. 打开手机微信,进入 `我 - 设置 - 通用 - 聊天记录迁移与备份`
|
||||||
|
2. 选择 `迁移 - 迁移到电脑`,按照提示操作
|
||||||
|
3. 完成迁移后,重新运行 `chatlog` 获取密钥并解密数据
|
||||||
|
|
||||||
|
> 此操作不会影响手机上的聊天记录,只是将数据复制到电脑端
|
||||||
|
|
||||||
|
## 平台特定说明
|
||||||
|
|
||||||
|
### Windows 版本说明
|
||||||
|
|
||||||
|
如遇到界面显示异常(如花屏、乱码等),请使用 [Windows Terminal](https://github.com/microsoft/terminal) 运行程序
|
||||||
|
|
||||||
|
### macOS 版本说明
|
||||||
|
|
||||||
|
macOS 用户在获取密钥前需要临时关闭 SIP(系统完整性保护):
|
||||||
|
|
||||||
|
1. **关闭 SIP**:
|
||||||
|
```shell
|
||||||
|
# 进入恢复模式
|
||||||
|
# Intel Mac: 重启时按住 Command + R
|
||||||
|
# Apple Silicon: 重启时长按电源键
|
||||||
|
|
||||||
|
# 在恢复模式中打开终端并执行
|
||||||
|
csrutil disable
|
||||||
|
|
||||||
|
# 重启系统
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **安装必要工具**:
|
||||||
|
```shell
|
||||||
|
# 安装 Xcode Command Line Tools
|
||||||
|
xcode-select --install
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **获取密钥后**:可以重新启用 SIP(`csrutil enable`),不影响后续使用
|
||||||
|
|
||||||
|
> Apple Silicon 用户注意:确保微信、chatlog 和终端都不在 Rosetta 模式下运行
|
||||||
|
|
||||||
## HTTP API
|
## HTTP API
|
||||||
|
|
||||||
启动 HTTP 服务后,可以通过以下 API 访问数据:
|
启动 HTTP 服务后(默认地址 `http://127.0.0.1:5030`),可通过以下 API 访问数据:
|
||||||
|
|
||||||
### 聊天记录
|
### 聊天记录查询
|
||||||
|
|
||||||
```
|
```
|
||||||
GET /api/v1/chatlog?time=2023-01-01&talker=wxid_xxx&limit=100&offset=0&format=json
|
GET /api/v1/chatlog?time=2023-01-01&talker=wxid_xxx
|
||||||
```
|
```
|
||||||
|
|
||||||
参数说明:
|
参数说明:
|
||||||
- `time`: 时间范围,格式为 `YYYY-MM-DD` 或 `YYYY-MM-DD~YYYY-MM-DD`
|
- `time`: 时间范围,格式为 `YYYY-MM-DD` 或 `YYYY-MM-DD~YYYY-MM-DD`
|
||||||
- `talker`: 聊天对象的 ID,不知道 ID 的话也可以尝试备注名、昵称、群聊 ID等
|
- `talker`: 聊天对象标识(支持 wxid、群聊 ID、备注名、昵称等)
|
||||||
- `limit`: 返回记录数量限制
|
- `limit`: 返回记录数量
|
||||||
- `offset`: 分页偏移量
|
- `offset`: 分页偏移量
|
||||||
- `format`: 输出格式,支持 `json`、`csv` 或纯文本
|
- `format`: 输出格式,支持 `json`、`csv` 或纯文本
|
||||||
|
|
||||||
### 联系人列表
|
### 其他 API 接口
|
||||||
|
|
||||||
```
|
- **联系人列表**:`GET /api/v1/contact`
|
||||||
GET /api/v1/contact?format=json
|
- **群聊列表**:`GET /api/v1/chatroom`
|
||||||
```
|
- **会话列表**:`GET /api/v1/session`
|
||||||
|
|
||||||
### 群聊列表
|
### 多媒体内容
|
||||||
|
|
||||||
```
|
聊天记录中的多媒体内容会通过 HTTP 服务进行提供,可通过以下路径访问:
|
||||||
GET /api/v1/chatroom?format=json
|
|
||||||
```
|
|
||||||
|
|
||||||
### 会话列表
|
- **图片内容**:`GET /image/<id>`
|
||||||
|
- **视频内容**:`GET /video/<id>`
|
||||||
|
- **文件内容**:`GET /file/<id>`
|
||||||
|
- **语音内容**:`GET /voice/<id>`
|
||||||
|
- **多媒体内容**:`GET /data/<data dir relative path>`
|
||||||
|
|
||||||
```
|
当请求图片、视频、文件内容时,将返回 302 跳转到多媒体内容 URL。
|
||||||
GET /api/v1/session?limit=100&format=json
|
当请求语音内容时,将直接返回语音内容,并对原始 SILK 语音做了实时转码 MP3 处理。
|
||||||
```
|
多媒体内容 URL 地址为基于`数据目录`的相对地址,请求多媒体内容将直接返回对应文件,并针对加密图片做了实时解密处理。
|
||||||
|
|
||||||
## MCP 集成
|
## MCP 集成
|
||||||
|
|
||||||
Chatlog 实现了 Model Context Protocol (MCP),可以与支持 MCP 的 AI 助手集成。通过 MCP,AI 助手可以:
|
Chatlog 支持 MCP (Model Context Protocol) SSE 协议,可与支持 MCP 的 AI 助手无缝集成。
|
||||||
|
启动 HTTP 服务后,通过 SSE Endpoint 访问服务:
|
||||||
|
|
||||||
1. 查询联系人信息
|
```
|
||||||
2. 获取群聊列表和成员
|
GET /sse
|
||||||
3. 检索最近的聊天记录
|
```
|
||||||
4. 按时间和联系人搜索聊天记录
|
|
||||||
|
|
||||||
## 未来规划
|
### 快速集成
|
||||||
|
|
||||||
Chatlog 希望成为最好用的聊天记录工具,帮助用户充分挖掘自己聊天数据的价值。我们的路线图包括:
|
Chatlog 可以与多种支持 MCP 的 AI 助手集成,包括:
|
||||||
|
|
||||||
- **多平台支持**:计划支持 MacOS 平台的微信聊天记录解密
|
- **ChatWise**: 直接支持 SSE,在工具设置中添加 `http://127.0.0.1:5030/sse`
|
||||||
- **全文索引**:实现聊天记录的全文检索,提供更快速的搜索体验
|
- **Cherry Studio**: 直接支持 SSE,在 MCP 服务器设置中添加 `http://127.0.0.1:5030/sse`
|
||||||
- **统计与可视化**:提供聊天数据的统计分析和可视化 Dashboard
|
|
||||||
- **CS 架构**:将数据收集和统计分析功能分离,支持将服务部署在 NAS 或家庭服务器上
|
|
||||||
- **增量更新**:支持聊天记录的增量采集和更新,减少资源消耗
|
|
||||||
- **关键词监控**:提供关键词监控和实时提醒功能
|
|
||||||
- **更多聊天工具支持**:计划支持更多主流聊天工具的数据采集和分析
|
|
||||||
|
|
||||||
## 数据安全声明
|
对于不直接支持 SSE 的客户端,可以使用 [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy) 工具转发请求:
|
||||||
|
|
||||||
Chatlog 高度重视用户数据安全和隐私保护:
|
- **Claude Desktop**: 通过 mcp-proxy 支持,需要配置 `claude_desktop_config.json`
|
||||||
|
- **Monica Code**: 通过 mcp-proxy 支持,需要配置 VSCode 插件设置
|
||||||
|
|
||||||
- 所有数据处理均在本地完成,不会上传到任何外部服务器
|
### 详细集成指南
|
||||||
- 解密后的数据存储在用户指定的工作目录中,用户对数据有完全控制权
|
|
||||||
- 建议定期备份重要的聊天记录,并妥善保管解密后的数据
|
|
||||||
- 请勿将本工具用于未经授权访问他人聊天记录等非法用途
|
|
||||||
|
|
||||||
## 贡献
|
查看 [MCP 集成指南](docs/mcp.md) 获取各平台的详细配置步骤和注意事项。
|
||||||
|
|
||||||
我们欢迎社区的贡献!无论是代码贡献、问题报告还是功能建议,都将帮助 Chatlog 变得更好:
|
## Prompt 示例
|
||||||
|
|
||||||
1. Fork 本仓库
|
为了帮助大家更好地利用 Chatlog 与 AI 助手,我们整理了一些 prompt 示例。希望这些 prompt 可以启发大家更有效地查询和分析聊天记录,获取更精准的信息。
|
||||||
2. 创建您的特性分支 (`git checkout -b feature/amazing-feature`)
|
|
||||||
3. 提交您的更改 (`git commit -m 'Add some amazing feature'`)
|
|
||||||
4. 推送到分支 (`git push origin feature/amazing-feature`)
|
|
||||||
5. 打开一个 Pull Request
|
|
||||||
|
|
||||||
## 许可证
|
查看 [Prompt 指南](docs/prompt.md) 获取详细示例。
|
||||||
|
|
||||||
本项目采用 Apache-2.0 许可证 - 详见 [LICENSE](LICENSE) 文件。
|
同时欢迎大家分享使用经验和 prompt!如果您有好的 prompt 示例或使用技巧,请通过 [Discussions](https://github.com/sjzar/chatlog/discussions) 进行分享,共同进步。
|
||||||
|
|
||||||
## 致谢
|
## 免责声明
|
||||||
|
|
||||||
- [tview](https://github.com/rivo/tview) - 终端 UI 库
|
⚠️ **重要提示:使用本项目前,请务必阅读并理解完整的 [免责声明](./DISCLAIMER.md)。**
|
||||||
- [gin](https://github.com/gin-gonic/gin) - HTTP 框架
|
|
||||||
- [Model Context Protocol](https://github.com/modelcontextprotocol) - AI 助手集成协议
|
本项目仅供学习、研究和个人合法使用,禁止用于任何非法目的或未授权访问他人数据。下载、安装或使用本工具即表示您同意遵守免责声明中的所有条款,并自行承担使用过程中的全部风险和法律责任。
|
||||||
- 以及所有贡献者和用户的支持与反馈
|
|
||||||
|
### 摘要(请阅读完整免责声明)
|
||||||
|
|
||||||
|
- 仅限处理您自己合法拥有的聊天数据或已获授权的数据
|
||||||
|
- 严禁用于未经授权获取、查看或分析他人聊天记录
|
||||||
|
- 开发者不对使用本工具可能导致的任何损失承担责任
|
||||||
|
- 使用第三方 LLM 服务时,您应遵守这些服务的使用条款和隐私政策
|
||||||
|
|
||||||
|
**本项目完全免费开源,任何以本项目名义收费的行为均与本项目无关。**
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
本项目基于 [Apache-2.0 许可证](./LICENSE) 开源。
|
||||||
|
|
||||||
|
## 隐私政策
|
||||||
|
|
||||||
|
本项目不收集任何用户数据。所有数据处理均在用户本地设备上进行。使用第三方服务时,请参阅相应服务的隐私政策。
|
||||||
|
|
||||||
|
## Thanks
|
||||||
|
|
||||||
|
- [@0xlane](https://github.com/0xlane) 的 [wechat-dump-rs](https://github.com/0xlane/wechat-dump-rs) 项目
|
||||||
|
- [@xaoyaoo](https://github.com/xaoyaoo) 的 [PyWxDump](https://github.com/xaoyaoo/PyWxDump) 项目
|
||||||
|
- [@git-jiadong](https://github.com/git-jiadong) 的 [go-lame](https://github.com/git-jiadong/go-lame) 和 [go-silk](https://github.com/git-jiadong/go-silk) 项目
|
||||||
|
- [Anthropic](https://www.anthropic.com/) 的 [MCP]((https://github.com/modelcontextprotocol) ) 协议
|
||||||
|
- 各个 Go 开源库的贡献者们
|
||||||
@@ -2,10 +2,11 @@ package chatlog
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/internal/chatlog"
|
"github.com/sjzar/chatlog/internal/chatlog"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -14,13 +15,17 @@ func init() {
|
|||||||
decryptCmd.Flags().StringVarP(&dataDir, "data-dir", "d", "", "data dir")
|
decryptCmd.Flags().StringVarP(&dataDir, "data-dir", "d", "", "data dir")
|
||||||
decryptCmd.Flags().StringVarP(&workDir, "work-dir", "w", "", "work dir")
|
decryptCmd.Flags().StringVarP(&workDir, "work-dir", "w", "", "work dir")
|
||||||
decryptCmd.Flags().StringVarP(&key, "key", "k", "", "key")
|
decryptCmd.Flags().StringVarP(&key, "key", "k", "", "key")
|
||||||
|
decryptCmd.Flags().StringVarP(&decryptPlatform, "platform", "p", runtime.GOOS, "platform")
|
||||||
decryptCmd.Flags().IntVarP(&decryptVer, "version", "v", 3, "version")
|
decryptCmd.Flags().IntVarP(&decryptVer, "version", "v", 3, "version")
|
||||||
}
|
}
|
||||||
|
|
||||||
var dataDir string
|
var (
|
||||||
var workDir string
|
dataDir string
|
||||||
var key string
|
workDir string
|
||||||
var decryptVer int
|
key string
|
||||||
|
decryptPlatform string
|
||||||
|
decryptVer int
|
||||||
|
)
|
||||||
|
|
||||||
var decryptCmd = &cobra.Command{
|
var decryptCmd = &cobra.Command{
|
||||||
Use: "decrypt",
|
Use: "decrypt",
|
||||||
@@ -28,11 +33,11 @@ var decryptCmd = &cobra.Command{
|
|||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
m, err := chatlog.New("")
|
m, err := chatlog.New("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Err(err).Msg("failed to create chatlog instance")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if err := m.CommandDecrypt(dataDir, workDir, key, decryptVer); err != nil {
|
if err := m.CommandDecrypt(dataDir, workDir, key, decryptPlatform, decryptVer); err != nil {
|
||||||
log.Error(err)
|
log.Err(err).Msg("failed to decrypt")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println("decrypt success")
|
fmt.Println("decrypt success")
|
||||||
|
|||||||
146
cmd/chatlog/cmd_dumpmemory.go
Normal file
146
cmd/chatlog/cmd_dumpmemory.go
Normal file
@@ -0,0 +1,146 @@
|
|||||||
|
package chatlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/zip"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/key/darwin/glance"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(dumpmemoryCmd)
|
||||||
|
}
|
||||||
|
|
||||||
|
var dumpmemoryCmd = &cobra.Command{
|
||||||
|
Use: "dumpmemory",
|
||||||
|
Short: "dump memory",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
if runtime.GOOS != "darwin" {
|
||||||
|
log.Info().Msg("dump memory only support macOS")
|
||||||
|
}
|
||||||
|
|
||||||
|
session := time.Now().Format("20060102150405")
|
||||||
|
|
||||||
|
dir, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("get current directory failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info().Msgf("current directory: %s", dir)
|
||||||
|
|
||||||
|
// step 1. check pid
|
||||||
|
if err = wechat.Load(); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("load wechat failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
accounts := wechat.GetAccounts()
|
||||||
|
if len(accounts) == 0 {
|
||||||
|
log.Fatal().Msg("no wechat account found")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("found %d wechat account", len(accounts))
|
||||||
|
for i, a := range accounts {
|
||||||
|
log.Info().Msgf("%d. %s %d %s", i, a.FullVersion, a.PID, a.DataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// step 2. dump memory
|
||||||
|
account := accounts[0]
|
||||||
|
file := fmt.Sprintf("wechat_%s_%d_%s.bin", account.FullVersion, account.PID, session)
|
||||||
|
path := filepath.Join(dir, file)
|
||||||
|
log.Info().Msgf("dumping memory to %s", path)
|
||||||
|
|
||||||
|
g := glance.NewGlance(account.PID)
|
||||||
|
b, err := g.Read()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("read memory failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err = os.WriteFile(path, b, 0644); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("write memory failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msg("dump memory success")
|
||||||
|
|
||||||
|
// step 3. copy encrypted database file
|
||||||
|
dbFile := "db_storage/session/session.db"
|
||||||
|
if account.Version == 3 {
|
||||||
|
dbFile = "Session/session_new.db"
|
||||||
|
}
|
||||||
|
from := filepath.Join(account.DataDir, dbFile)
|
||||||
|
to := filepath.Join(dir, fmt.Sprintf("wechat_%s_%d_session.db", account.FullVersion, account.PID))
|
||||||
|
|
||||||
|
log.Info().Msgf("copying %s to %s", from, to)
|
||||||
|
b, err = os.ReadFile(from)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("read session.db failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err = os.WriteFile(to, b, 0644); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("write session.db failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
log.Info().Msg("copy session.db success")
|
||||||
|
|
||||||
|
// step 4. package
|
||||||
|
zipFile := fmt.Sprintf("wechat_%s_%d_%s.zip", account.FullVersion, account.PID, session)
|
||||||
|
zipPath := filepath.Join(dir, zipFile)
|
||||||
|
log.Info().Msgf("packaging to %s", zipPath)
|
||||||
|
|
||||||
|
zf, err := os.Create(zipPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("create zip file failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer zf.Close()
|
||||||
|
|
||||||
|
zw := zip.NewWriter(zf)
|
||||||
|
|
||||||
|
for _, file := range []string{file, to} {
|
||||||
|
f, err := os.Open(file)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("open file failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("get file info failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
header, err := zip.FileInfoHeader(info)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("create zip file info header failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
header.Name = filepath.Base(file)
|
||||||
|
header.Method = zip.Deflate
|
||||||
|
writer, err := zw.CreateHeader(header)
|
||||||
|
if err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("create zip file header failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if _, err = io.Copy(writer, f); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("copy file to zip failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err = zw.Close(); err != nil {
|
||||||
|
log.Fatal().Err(err).Msg("close zip writer failed")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msgf("package success, please send %s to developer", zipPath)
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
|
|
||||||
"github.com/sjzar/chatlog/internal/chatlog"
|
"github.com/sjzar/chatlog/internal/chatlog"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -21,12 +21,12 @@ var keyCmd = &cobra.Command{
|
|||||||
Run: func(cmd *cobra.Command, args []string) {
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
m, err := chatlog.New("")
|
m, err := chatlog.New("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Err(err).Msg("failed to create chatlog instance")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
ret, err := m.CommandKey(pid)
|
ret, err := m.CommandKey(pid)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Err(err).Msg("failed to get key")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println(ret)
|
fmt.Println(ret)
|
||||||
|
|||||||
43
cmd/chatlog/cmd_server.go
Normal file
43
cmd/chatlog/cmd_server.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package chatlog
|
||||||
|
|
||||||
|
import (
|
||||||
|
"runtime"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/chatlog"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/spf13/cobra"
|
||||||
|
)
|
||||||
|
|
||||||
|
func init() {
|
||||||
|
rootCmd.AddCommand(serverCmd)
|
||||||
|
serverCmd.Flags().StringVarP(&serverAddr, "addr", "a", "127.0.0.1:5030", "server address")
|
||||||
|
serverCmd.Flags().StringVarP(&serverDataDir, "data-dir", "d", "", "data dir")
|
||||||
|
serverCmd.Flags().StringVarP(&serverWorkDir, "work-dir", "w", "", "work dir")
|
||||||
|
serverCmd.Flags().StringVarP(&serverPlatform, "platform", "p", runtime.GOOS, "platform")
|
||||||
|
serverCmd.Flags().IntVarP(&serverVer, "version", "v", 3, "version")
|
||||||
|
}
|
||||||
|
|
||||||
|
var (
|
||||||
|
serverAddr string
|
||||||
|
serverDataDir string
|
||||||
|
serverWorkDir string
|
||||||
|
serverPlatform string
|
||||||
|
serverVer int
|
||||||
|
)
|
||||||
|
|
||||||
|
var serverCmd = &cobra.Command{
|
||||||
|
Use: "server",
|
||||||
|
Short: "Start HTTP server",
|
||||||
|
Run: func(cmd *cobra.Command, args []string) {
|
||||||
|
m, err := chatlog.New("")
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("failed to create chatlog instance")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := m.CommandHTTPServer(serverAddr, serverDataDir, serverWorkDir, serverPlatform, serverVer); err != nil {
|
||||||
|
log.Err(err).Msg("failed to start server")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
@@ -1,34 +1,29 @@
|
|||||||
package chatlog
|
package chatlog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
|
||||||
"io"
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"runtime"
|
"time"
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/pkg/util"
|
"github.com/sjzar/chatlog/pkg/util"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/sirupsen/logrus"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
var Debug bool
|
var Debug bool
|
||||||
|
|
||||||
func initLog(cmd *cobra.Command, args []string) {
|
func initLog(cmd *cobra.Command, args []string) {
|
||||||
log.SetFormatter(&log.TextFormatter{
|
zerolog.SetGlobalLevel(zerolog.InfoLevel)
|
||||||
FullTimestamp: true,
|
|
||||||
CallerPrettyfier: func(f *runtime.Frame) (string, string) {
|
|
||||||
_, filename := path.Split(f.File)
|
|
||||||
return "", fmt.Sprintf("%s:%d", filename, f.Line)
|
|
||||||
},
|
|
||||||
})
|
|
||||||
|
|
||||||
if Debug {
|
if Debug {
|
||||||
log.SetLevel(log.DebugLevel)
|
zerolog.SetGlobalLevel(zerolog.DebugLevel)
|
||||||
log.SetReportCaller(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
|
||||||
}
|
}
|
||||||
|
|
||||||
func initTuiLog(cmd *cobra.Command, args []string) {
|
func initTuiLog(cmd *cobra.Command, args []string) {
|
||||||
@@ -43,8 +38,8 @@ func initTuiLog(cmd *cobra.Command, args []string) {
|
|||||||
panic(err)
|
panic(err)
|
||||||
}
|
}
|
||||||
logOutput = logFD
|
logOutput = logFD
|
||||||
log.SetReportCaller(true)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
log.SetOutput(logOutput)
|
log.Logger = log.Output(zerolog.ConsoleWriter{Out: logOutput, NoColor: true, TimeFormat: time.RFC3339})
|
||||||
|
logrus.SetOutput(logOutput)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package chatlog
|
|||||||
import (
|
import (
|
||||||
"github.com/sjzar/chatlog/internal/chatlog"
|
"github.com/sjzar/chatlog/internal/chatlog"
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/spf13/cobra"
|
"github.com/spf13/cobra"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -17,7 +17,7 @@ func init() {
|
|||||||
|
|
||||||
func Execute() {
|
func Execute() {
|
||||||
if err := rootCmd.Execute(); err != nil {
|
if err := rootCmd.Execute(); err != nil {
|
||||||
log.Error(err)
|
log.Err(err).Msg("command execution failed")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,11 +38,11 @@ func Root(cmd *cobra.Command, args []string) {
|
|||||||
|
|
||||||
m, err := chatlog.New("")
|
m, err := chatlog.New("")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Error(err)
|
log.Err(err).Msg("failed to create chatlog instance")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.Run(); err != nil {
|
if err := m.Run(); err != nil {
|
||||||
log.Error(err)
|
log.Err(err).Msg("failed to run chatlog instance")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
151
docs/mcp.md
Normal file
151
docs/mcp.md
Normal file
@@ -0,0 +1,151 @@
|
|||||||
|
# MCP 集成指南
|
||||||
|
|
||||||
|
## 目录
|
||||||
|
- [MCP 集成指南](#mcp-集成指南)
|
||||||
|
- [目录](#目录)
|
||||||
|
- [前期准备](#前期准备)
|
||||||
|
- [mcp-proxy](#mcp-proxy)
|
||||||
|
- [ChatWise](#chatwise)
|
||||||
|
- [Cherry Studio](#cherry-studio)
|
||||||
|
- [Claude Desktop](#claude-desktop)
|
||||||
|
- [Monica Code](#monica-code)
|
||||||
|
|
||||||
|
|
||||||
|
## 前期准备
|
||||||
|
|
||||||
|
运行 `chatlog`,完成数据解密并开启 HTTP 服务
|
||||||
|
|
||||||
|
### mcp-proxy
|
||||||
|
如果遇到不支持 `SSE` 的客户端,可以尝试使用 `mcp-proxy` 将 `stdio` 的请求转换为 `SSE`。
|
||||||
|
|
||||||
|
项目地址:https://github.com/sparfenyuk/mcp-proxy
|
||||||
|
|
||||||
|
安装方式:
|
||||||
|
```shell
|
||||||
|
# 使用 uv 工具安装,也可参考项目文档的其他安装方式
|
||||||
|
uv tool install mcp-proxy
|
||||||
|
|
||||||
|
# 查询 mcp-proxy 的路径,后续可直接使用该路径
|
||||||
|
which mcp-proxy
|
||||||
|
/Users/sarv/.local/bin/mcp-proxy
|
||||||
|
```
|
||||||
|
|
||||||
|
## ChatWise
|
||||||
|
|
||||||
|
- 官网:https://chatwise.app/
|
||||||
|
- 使用方式:MCP SSE
|
||||||
|
- 注意事项:使用 ChatWise 的 MCP 功能需要 Pro 权限
|
||||||
|
|
||||||
|
1. 在 `设置 - 工具` 下新建 `SSE 请求` 工具
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
1. 在 URL 中填写 `http://127.0.0.1:5030/sse`,并勾选 `自动执行工具`,点击 `查看工具` 即可检查连接 `chatlog` 是否正常
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
3. 返回主页,选择支持 MCP 调用的模型,打开 `chatlog` 工具选项
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
4. 测试功能是否正常
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Cherry Studio
|
||||||
|
|
||||||
|
- 官网:https://cherry-ai.com/
|
||||||
|
- 使用方式:MCP SSE
|
||||||
|
|
||||||
|
1. 在 `设置 - MCP 服务器` 下点击 `添加服务器`,输入名称为 `chatlog`,选择类型为 `服务器发送事件(sse)`,填写 URL 为 `http://127.0.0.1:5030/sse`,点击 `保存`。(注意:点击保存前不要先点击左侧的开启按钮)
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
2. 选择支持 MCP 调用的模型,打开 `chatlog` 工具选项
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
3. 测试功能是否正常
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## Claude Desktop
|
||||||
|
|
||||||
|
- 官网:https://claude.ai/download
|
||||||
|
- 使用方式:mcp-proxy
|
||||||
|
- 参考资料:https://modelcontextprotocol.io/quickstart/user#2-add-the-filesystem-mcp-server
|
||||||
|
|
||||||
|
1. 请先参考 [mcp-proxy](#mcp-proxy) 安装 `mcp-proxy`
|
||||||
|
|
||||||
|
2. 进入 Claude Desktop `Settings - Developer`,点击 `Edit Config` 按钮,这样会创建一个 `claude_desktop_config.json` 配置文件,并引导你编辑该文件
|
||||||
|
|
||||||
|
3. 编辑 `claude_desktop_config.json` 文件,配置名称为 `chatlog`,command 为 `mcp-proxy` 的路径,args 为 `http://127.0.0.1:5030/sse`,如下所示:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"chatlog": {
|
||||||
|
"command": "/Users/sarv/.local/bin/mcp-proxy",
|
||||||
|
"args": [
|
||||||
|
"http://localhost:5030/sse"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"globalShortcut": ""
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
4. 保存 `claude_desktop_config.json` 文件,重启 Claude Desktop,可以看到 `chatlog` 已经添加成功
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
5. 测试功能是否正常
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
|
||||||
|
## Monica Code
|
||||||
|
|
||||||
|
- 官网:https://monica.im/en/code
|
||||||
|
- 使用方式:mcp-proxy
|
||||||
|
- 参考资料:https://github.com/Monica-IM/Monica-Code/blob/main/Reference/config.md#modelcontextprotocolserver
|
||||||
|
|
||||||
|
1. 请先参考 [mcp-proxy](#mcp-proxy) 安装 `mcp-proxy`
|
||||||
|
|
||||||
|
2. 在 vscode 插件文件夹(`~/.vscode/extensions`)下找到 Monica Code 的目录,编辑 `config_schema.json` 文件。将 `experimental - modelContextProtocolServer` 中 `transport` 设置为如下内容:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"experimental": {
|
||||||
|
"type": "object",
|
||||||
|
"title": "Experimental",
|
||||||
|
"description": "Experimental properties are subject to change.",
|
||||||
|
"properties": {
|
||||||
|
"modelContextProtocolServer": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"transport": {
|
||||||
|
"type": "stdio",
|
||||||
|
"command": "/Users/sarv/.local/bin/mcp-proxy",
|
||||||
|
"args": [
|
||||||
|
"http://localhost:5030/sse"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"required": [
|
||||||
|
"transport"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
3. 重启 vscode,可以看到 `chatlog` 已经添加成功
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
4. 测试功能是否正常
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
70
docs/prompt.md
Normal file
70
docs/prompt.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# Prompt 指南
|
||||||
|
|
||||||
|
## 概述
|
||||||
|
优秀的 `prompt` 可以极大的提高 `chatlog` 使用体验,收集了部分群友分享的 `prompt`,供大家参考。
|
||||||
|
在处理聊天记录时,尽量选择上下文长度足够的 LLM,例如 `Gemini 2.5 Pro`、`Claude 3.5 Sonnet` 等。
|
||||||
|
欢迎大家在 [Discussions](https://github.com/sjzar/chatlog/discussions/47) 中分享自己的使用方式,共同进步。
|
||||||
|
|
||||||
|
|
||||||
|
## 群聊总结
|
||||||
|
作者:@eyaeya
|
||||||
|
|
||||||
|
```md
|
||||||
|
你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
|
||||||
|
|
||||||
|
请帮我将 "<talker>" 在 <Time> 的群聊内容总结成一个群聊报告,包含不多于5个的话题的总结(如果还有更多话题,可以在后面简单补充)。每个话题包含以下内容:
|
||||||
|
- 话题名(50字以内,带序号1️⃣2️⃣3️⃣,同时附带热度,以🔥数量表示)
|
||||||
|
- 参与者(不超过5个人,将重复的人名去重)
|
||||||
|
- 时间段(从几点到几点)
|
||||||
|
- 过程(50到200字左右)
|
||||||
|
- 评价(50字以下)
|
||||||
|
- 分割线: ------------
|
||||||
|
|
||||||
|
另外有以下要求:
|
||||||
|
1. 每个话题结束使用 ------------ 分割
|
||||||
|
2. 使用中文冒号
|
||||||
|
3. 无需大标题
|
||||||
|
4. 开始给出本群讨论风格的整体评价,例如活跃、太水、太黄、太暴力、话题不集中、无聊诸如此类
|
||||||
|
|
||||||
|
最后总结下最活跃的前五个发言者。
|
||||||
|
```
|
||||||
|
|
||||||
|
## 微信聊天记录可视化
|
||||||
|
作者:@数字声明卡兹克
|
||||||
|
原文地址:https://mp.weixin.qq.com/s/Z66YRjY1EnC_hMgXE9_nnw
|
||||||
|
Prompt:[微信聊天记录可视化prompt.txt](https://github.com/user-attachments/files/19773263/prompt.txt)
|
||||||
|
|
||||||
|
这份 prompt 可以使用聊天记录生成 HTML 网页,再使用 [YOURWARE](https://www.yourware.so/) 部署为可分享的静态网页。
|
||||||
|
|
||||||
|
### 技术讨论分析
|
||||||
|
作者:@eyaeya
|
||||||
|
|
||||||
|
```md
|
||||||
|
你作为一个专业的技术讨论分析者,请对以下聊天记录进行分析和结构化总结:
|
||||||
|
|
||||||
|
1. 基础信息提取:
|
||||||
|
- 将每个主题分成独立的问答对
|
||||||
|
- 保持原始对话的时间顺序
|
||||||
|
|
||||||
|
1. 问题分析要点:
|
||||||
|
- 提取问题的具体场景和背景
|
||||||
|
- 识别问题的核心技术难点
|
||||||
|
- 突出问题的实际影响
|
||||||
|
|
||||||
|
1. 解决方案总结:
|
||||||
|
- 列出具体的解决步骤
|
||||||
|
- 提取关键工具和资源
|
||||||
|
- 包含实践经验和注意事项
|
||||||
|
- 保留重要的链接和参考资料
|
||||||
|
|
||||||
|
1. 输出格式:
|
||||||
|
- 不要输出"日期:YYYY-MM-DD"这一行,直接从问题1开始
|
||||||
|
- 问题1:<简明扼要的问题描述>
|
||||||
|
- 回答1:<完整的解决方案>
|
||||||
|
- 补充:<额外的讨论要点或注意事项>
|
||||||
|
|
||||||
|
1. 额外要求(严格执行):
|
||||||
|
- 如果有多个相关问题,保持逻辑顺序
|
||||||
|
- 标记重要的警告和建议、突出经验性的分享内容、保留有价值的专业术语解释、移除"我来分析"等过渡语确保链接的完整性
|
||||||
|
- 直接以日期开始,不要添加任何开场白
|
||||||
|
```
|
||||||
76
go.mod
76
go.mod
@@ -5,68 +5,68 @@ go 1.24.0
|
|||||||
require (
|
require (
|
||||||
github.com/gdamore/tcell/v2 v2.8.1
|
github.com/gdamore/tcell/v2 v2.8.1
|
||||||
github.com/gin-gonic/gin v1.10.0
|
github.com/gin-gonic/gin v1.10.0
|
||||||
github.com/google/uuid v1.4.0
|
github.com/google/uuid v1.6.0
|
||||||
github.com/mattn/go-sqlite3 v1.14.24
|
github.com/klauspost/compress v1.18.0
|
||||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57
|
github.com/mattn/go-sqlite3 v1.14.27
|
||||||
github.com/shirou/gopsutil/v4 v4.25.2
|
github.com/pierrec/lz4/v4 v4.1.22
|
||||||
|
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922
|
||||||
|
github.com/rs/zerolog v1.34.0
|
||||||
|
github.com/shirou/gopsutil/v4 v4.25.3
|
||||||
github.com/sirupsen/logrus v1.9.3
|
github.com/sirupsen/logrus v1.9.3
|
||||||
|
github.com/sjzar/go-lame v0.0.8
|
||||||
|
github.com/sjzar/go-silk v0.0.1
|
||||||
github.com/spf13/cobra v1.9.1
|
github.com/spf13/cobra v1.9.1
|
||||||
github.com/spf13/viper v1.19.0
|
github.com/spf13/viper v1.20.1
|
||||||
golang.org/x/crypto v0.36.0
|
golang.org/x/crypto v0.37.0
|
||||||
golang.org/x/sys v0.31.0
|
golang.org/x/sys v0.32.0
|
||||||
google.golang.org/protobuf v1.36.5
|
google.golang.org/protobuf v1.36.6
|
||||||
|
howett.net/plist v1.0.1
|
||||||
)
|
)
|
||||||
|
|
||||||
require (
|
require (
|
||||||
github.com/bytedance/sonic v1.11.6 // indirect
|
github.com/bytedance/sonic v1.13.2 // indirect
|
||||||
github.com/bytedance/sonic/loader v0.1.1 // indirect
|
github.com/bytedance/sonic/loader v0.2.4 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.4 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/cloudwego/iasm v0.2.0 // indirect
|
|
||||||
github.com/ebitengine/purego v0.8.2 // indirect
|
github.com/ebitengine/purego v0.8.2 // indirect
|
||||||
github.com/fsnotify/fsnotify v1.7.0 // indirect
|
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
|
||||||
github.com/gdamore/encoding v1.0.1 // indirect
|
github.com/gdamore/encoding v1.0.1 // indirect
|
||||||
github.com/gin-contrib/sse v0.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-ole/go-ole v1.3.0 // indirect
|
github.com/go-ole/go-ole v1.3.0 // indirect
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.20.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
github.com/goccy/go-json v0.10.2 // indirect
|
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
|
||||||
github.com/hashicorp/hcl v1.0.0 // indirect
|
github.com/goccy/go-json v0.10.5 // indirect
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||||
github.com/json-iterator/go v1.1.12 // indirect
|
github.com/json-iterator/go v1.1.12 // indirect
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 // indirect
|
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
|
||||||
github.com/leodido/go-urn v1.4.0 // indirect
|
github.com/leodido/go-urn v1.4.0 // indirect
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 // indirect
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
|
||||||
github.com/magiconair/properties v1.8.7 // indirect
|
github.com/mattn/go-colorable v0.1.14 // indirect
|
||||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||||
github.com/mitchellh/mapstructure v1.5.0 // indirect
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
|
||||||
github.com/modern-go/reflect2 v1.0.2 // indirect
|
github.com/modern-go/reflect2 v1.0.2 // indirect
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 // indirect
|
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c // indirect
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
github.com/sagikazarmark/locafero v0.4.0 // indirect
|
github.com/sagikazarmark/locafero v0.9.0 // indirect
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 // indirect
|
|
||||||
github.com/sourcegraph/conc v0.3.0 // indirect
|
github.com/sourcegraph/conc v0.3.0 // indirect
|
||||||
github.com/spf13/afero v1.11.0 // indirect
|
github.com/spf13/afero v1.14.0 // indirect
|
||||||
github.com/spf13/cast v1.6.0 // indirect
|
github.com/spf13/cast v1.7.1 // indirect
|
||||||
github.com/spf13/pflag v1.0.6 // indirect
|
github.com/spf13/pflag v1.0.6 // indirect
|
||||||
github.com/subosito/gotenv v1.6.0 // indirect
|
github.com/subosito/gotenv v1.6.0 // indirect
|
||||||
github.com/tklauser/go-sysconf v0.3.12 // indirect
|
github.com/tklauser/go-sysconf v0.3.15 // indirect
|
||||||
github.com/tklauser/numcpus v0.6.1 // indirect
|
github.com/tklauser/numcpus v0.10.0 // indirect
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
|
||||||
github.com/ugorji/go/codec v1.2.12 // indirect
|
github.com/ugorji/go/codec v1.2.12 // indirect
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
github.com/yusufpapurcu/wmi v1.2.4 // indirect
|
||||||
go.uber.org/atomic v1.9.0 // indirect
|
go.uber.org/multierr v1.11.0 // indirect
|
||||||
go.uber.org/multierr v1.9.0 // indirect
|
golang.org/x/arch v0.16.0 // indirect
|
||||||
golang.org/x/arch v0.8.0 // indirect
|
golang.org/x/net v0.39.0 // indirect
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 // indirect
|
golang.org/x/term v0.31.0 // indirect
|
||||||
golang.org/x/net v0.33.0 // indirect
|
golang.org/x/text v0.24.0 // indirect
|
||||||
golang.org/x/term v0.30.0 // indirect
|
|
||||||
golang.org/x/text v0.23.0 // indirect
|
|
||||||
gopkg.in/ini.v1 v1.67.0 // indirect
|
|
||||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
174
go.sum
174
go.sum
@@ -1,30 +1,30 @@
|
|||||||
github.com/bytedance/sonic v1.11.6 h1:oUp34TzMlL+OY1OUWxHqsdkgC/Zfc85zGqw9siXjrc0=
|
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||||
github.com/bytedance/sonic v1.11.6/go.mod h1:LysEHSvpvDySVdC2f87zGWf6CIKJcAvqab1ZaiQtds4=
|
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
|
||||||
github.com/bytedance/sonic/loader v0.1.1 h1:c+e5Pt1k/cy5wMveRDyk2X4B9hF4g7an8N3zCYjJFNM=
|
|
||||||
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
|
||||||
github.com/cloudwego/base64x v0.1.4 h1:jwCgWpFanWmN8xoIUHa2rtzmkd5J2plF/dnLS6Xd/0Y=
|
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
|
||||||
github.com/cloudwego/base64x v0.1.4/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
|
||||||
github.com/cloudwego/iasm v0.2.0 h1:1KNIy1I1H9hNNFEEH3DVnI4UujN+1zjpuk6gwHLTssg=
|
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
|
||||||
|
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
|
||||||
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
|
||||||
|
github.com/coreos/go-systemd/v22 v22.5.0/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc=
|
||||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||||
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
|
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM=
|
|
||||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
|
||||||
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
|
||||||
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
|
||||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||||
github.com/fsnotify/fsnotify v1.7.0 h1:8JEhPFa5W2WU7YfeZzPNqzMP6Lwt7L2715Ggo0nosvA=
|
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||||
github.com/fsnotify/fsnotify v1.7.0/go.mod h1:40Bi/Hjc2AVfZrqy+aj+yEI+/bRxZnMJyTJwOpGvigM=
|
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3 h1:in2uUcidCuFcDKtdcBxlR0rJ1+fsokWf+uqxgUFjbI0=
|
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
|
||||||
github.com/gabriel-vasile/mimetype v1.4.3/go.mod h1:d8uq/6HKRL6CGdk+aubisF/M5GcPfT7nKyLpA0lbSSk=
|
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
|
||||||
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
|
||||||
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
|
||||||
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
|
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
|
||||||
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
|
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
|
||||||
github.com/gin-contrib/sse v0.1.0 h1:Y/yl/+YNO8GZSjAhjMsSuLt29uWRFHdHYUb5lYOV9qE=
|
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
|
||||||
github.com/gin-contrib/sse v0.1.0/go.mod h1:RHrZQHXnP2xjPF+u1gW/2HnVO7nvIa9PG3Gm+fLHvGI=
|
github.com/gin-contrib/sse v1.1.0/go.mod h1:hxRZ5gVpWMT7Z0B0gSNYqqsSCNIJMjzvm6fqCz9vjwM=
|
||||||
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
github.com/gin-gonic/gin v1.10.0 h1:nTuyha1TYqgedzytsKYqna+DfLos46nTv2ygFy86HFU=
|
||||||
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
github.com/gin-gonic/gin v1.10.0/go.mod h1:4PMNQiOhvDRa013RKVbsiNwoyezlm2rm0uX/T7kzp5Y=
|
||||||
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
github.com/go-ole/go-ole v1.2.6/go.mod h1:pprOEPIfldk/42T2oK7lQ4v4JSDwmV0As9GaiUsvbm0=
|
||||||
@@ -36,26 +36,29 @@ github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/o
|
|||||||
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
|
||||||
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
|
||||||
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
|
||||||
github.com/go-playground/validator/v10 v10.20.0 h1:K9ISHbSaI0lyB2eWMPJo+kOS/FBExVwjEviJTixqxL8=
|
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
|
||||||
github.com/go-playground/validator/v10 v10.20.0/go.mod h1:dbuPbCMFw/DrkbEynArYaCwl3amGuJotoKCe95atGMM=
|
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
|
||||||
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
|
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
|
||||||
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
|
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
|
||||||
github.com/google/go-cmp v0.5.6/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
|
||||||
|
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
|
||||||
|
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
|
||||||
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
|
||||||
github.com/google/uuid v1.4.0 h1:MtMxsa51/r9yyhkyLsVeVt0B+BGQZzpQiTQ4eHZ8bc4=
|
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||||
github.com/google/uuid v1.4.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||||
github.com/hashicorp/hcl v1.0.0 h1:0Anlzjpi4vEasTeNFn2mLJgTSwt0+6sfsiTG8qcWGx4=
|
|
||||||
github.com/hashicorp/hcl v1.0.0/go.mod h1:E5yfLk+7swimpb2L/Alb/PJmXilQ/rhwaUYs4T20WEQ=
|
|
||||||
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
|
||||||
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
|
||||||
|
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
|
||||||
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
|
||||||
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
|
||||||
|
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
|
||||||
|
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
|
||||||
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7 h1:ZWSB3igEs+d0qvnxR/ZBzXVmxkgt8DdzP6m9pfuVLDM=
|
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
|
||||||
github.com/klauspost/cpuid/v2 v2.2.7/go.mod h1:Lcz8mBdAVJIBVzewtcLocK12l3Y+JytZYpaMropDUws=
|
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
|
||||||
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
|
||||||
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
|
||||||
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
|
||||||
@@ -65,78 +68,83 @@ github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
|
|||||||
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4=
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
|
||||||
github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0/go.mod h1:zJYVVT2jmtg6P3p1VtQj7WsuWi/y4VnjVBn7F8KPB3I=
|
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
|
||||||
github.com/magiconair/properties v1.8.7 h1:IeQXZAiQcpL9mgcAe1Nu6cX9LLw6ExEHKjN0VQdvPDY=
|
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
|
||||||
github.com/magiconair/properties v1.8.7/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
|
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
|
||||||
|
github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8=
|
||||||
|
github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM=
|
||||||
|
github.com/mattn/go-isatty v0.0.19/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM=
|
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
|
||||||
github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
|
||||||
github.com/mitchellh/mapstructure v1.5.0 h1:jeMsZIYE/09sWLaz43PL7Gy6RuMjD2eJVyuac5Z2hdY=
|
|
||||||
github.com/mitchellh/mapstructure v1.5.0/go.mod h1:bFUtVrKA4DC2yAKiSyO/QUcy7e+RRV2QTWOzhPopBRo=
|
|
||||||
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
|
||||||
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
|
||||||
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
|
||||||
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2 h1:aYUidT7k73Pcl9nb2gScu7NSrKCSHIDE89b3+6Wq+LM=
|
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||||
github.com/pelletier/go-toml/v2 v2.2.2/go.mod h1:1t835xjRzz80PqgE6HHgN2JOsmgYu/h4qDAS4n929Rs=
|
github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22 h1:cKFw6uJDK+/gfw5BcDL0JL5aBsAFdsIT18eRtLj7VIU=
|
||||||
|
github.com/pierrec/lz4/v4 v4.1.22/go.mod h1:gZWDp/Ze/IJXGXf23ltt2EXimqmTUXEy0GFuRQyBid4=
|
||||||
|
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
|
||||||
|
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
|
||||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c h1:ncq/mPwQF4JjgDlrVEn3C11VoGHZN7m8qihwgMEtzYw=
|
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922 h1:SMyqkaRfpE8ZQUSRTZKO3uN84xov++OGa+e3NCksaQw=
|
||||||
github.com/power-devops/perfstat v0.0.0-20210106213030-5aafc221ea8c/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
|
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
|
||||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57 h1:LmsF7Fk5jyEDhJk0fYIqdWNuTxSyid2W42A0L2YWjGE=
|
|
||||||
github.com/rivo/tview v0.0.0-20241227133733-17b7edb88c57/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
|
|
||||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||||
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||||
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
github.com/rogpeppe/go-internal v1.9.0 h1:73kH8U+JUqXU8lRuOHeVHaa/SZPifC7BkcraZVejAe8=
|
||||||
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
github.com/rogpeppe/go-internal v1.9.0/go.mod h1:WtVeX8xhTBvf0smdhujwtBcq4Qrzq/fJaraNFVN+nFs=
|
||||||
|
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
|
||||||
|
github.com/rs/zerolog v1.34.0 h1:k43nTLIwcTVQAncfCw4KZ2VY6ukYoZaBPNOE8txlOeY=
|
||||||
|
github.com/rs/zerolog v1.34.0/go.mod h1:bJsvje4Z08ROH4Nhs5iH600c3IkWhwp44iRc54W6wYQ=
|
||||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||||
github.com/sagikazarmark/locafero v0.4.0 h1:HApY1R9zGo4DBgr7dqsTH/JJxLTTsOt7u6keLGt6kNQ=
|
github.com/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
|
||||||
github.com/sagikazarmark/locafero v0.4.0/go.mod h1:Pe1W6UlPYUk/+wc/6KFhbORCfqzgYEpgQ3O5fPuL3H4=
|
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0 h1:diDBnUNK9N/354PgrxMywXnAwEr1QZcOr6gto+ugjYE=
|
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
|
||||||
github.com/sagikazarmark/slog-shim v0.1.0/go.mod h1:SrcSrq8aKtyuqEI1uvTDTK1arOWRIczQRv+GVI1AkeQ=
|
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
|
||||||
github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk=
|
|
||||||
github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA=
|
|
||||||
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
|
||||||
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
github.com/sirupsen/logrus v1.9.3/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ=
|
||||||
|
github.com/sjzar/go-lame v0.0.8 h1:AS9l32R6foMiMEXWfUY8i79WIMfDoBC2QqQ9s5yziIk=
|
||||||
|
github.com/sjzar/go-lame v0.0.8/go.mod h1:8RmqWcAKSbBAk6bTRV9d8mdDxqK3hY9vFyoJ4DoQE6Y=
|
||||||
|
github.com/sjzar/go-silk v0.0.1 h1:cXD9dsIZti3n+g0Fd3IUvLH9A7tyL4jvUsHEyhff21s=
|
||||||
|
github.com/sjzar/go-silk v0.0.1/go.mod h1:IXVcHEXKiU9j3ZtHEiGS37OFKkex9pdAhZVcFzAIOlM=
|
||||||
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
github.com/sourcegraph/conc v0.3.0 h1:OQTbbt6P72L20UqAkXXuLOj79LfEanQ+YQFNpLA9ySo=
|
||||||
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
github.com/sourcegraph/conc v0.3.0/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
|
||||||
github.com/spf13/afero v1.11.0 h1:WJQKhtpdm3v2IzqG8VMqrr6Rf3UYpEF239Jy9wNepM8=
|
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
|
||||||
github.com/spf13/afero v1.11.0/go.mod h1:GH9Y3pIexgf1MTIWtNGyogA5MwRIDXGUr+hbWNoBjkY=
|
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
|
||||||
github.com/spf13/cast v1.6.0 h1:GEiTHELF+vaR5dhz3VqZfFSzZjYbgeKDpBxQVS4GYJ0=
|
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
|
||||||
github.com/spf13/cast v1.6.0/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
|
||||||
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
|
||||||
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
|
||||||
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
|
||||||
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
|
||||||
github.com/spf13/viper v1.19.0 h1:RWq5SEjt8o25SROyN3z2OrDB9l7RPd3lwTWU8EcEdcI=
|
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
|
||||||
github.com/spf13/viper v1.19.0/go.mod h1:GQUN9bilAbhU/jgc1bKs99f/suXKeUMct8Adx5+Ntkg=
|
github.com/spf13/viper v1.20.1/go.mod h1:P9Mdzt1zoHIG8m2eZQinpiBjo6kCmZSKBClNNqjJvu4=
|
||||||
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
|
||||||
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
github.com/stretchr/objx v0.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
|
||||||
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
|
||||||
github.com/stretchr/objx v0.5.2/go.mod h1:FRsXN1f5AsAjCGJKqEizvkpNtU+EGNCLh3NxZ/8L+MA=
|
|
||||||
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
|
||||||
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
github.com/stretchr/testify v1.7.1/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
|
||||||
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
github.com/stretchr/testify v1.8.0/go.mod h1:yNjHg4UonilssWZ8iaSj1OCr/vHnekPRkoO+kdMU+MU=
|
||||||
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
|
||||||
github.com/stretchr/testify v1.8.4/go.mod h1:sz/lmYIOXD/1dqDmKjjqLyZ2RngseejIcXlSw2iwfAo=
|
|
||||||
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
|
||||||
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
|
||||||
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
|
||||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||||
github.com/tklauser/go-sysconf v0.3.12 h1:0QaGUFOdQaIVdPgfITYzaTegZvdCjmYO52cSFAEVmqU=
|
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
|
||||||
github.com/tklauser/go-sysconf v0.3.12/go.mod h1:Ho14jnntGE1fpdOqQEEaiKRpvIavV0hSfmBq8nJbHYI=
|
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
|
||||||
github.com/tklauser/numcpus v0.6.1 h1:ng9scYS7az0Bk4OZLvrNXNSAO2Pxr1XXRAPyjhIx+Fk=
|
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
|
||||||
github.com/tklauser/numcpus v0.6.1/go.mod h1:1XfjsgE2zo8GVw7POkMbHENHzVg3GzmoZ9fESEdAacY=
|
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
|
||||||
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
|
||||||
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
|
||||||
@@ -144,22 +152,17 @@ github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZ
|
|||||||
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
|
||||||
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
|
||||||
go.uber.org/atomic v1.9.0 h1:ECmE8Bn/WFTYwEW/bpKD3M8VtR/zQVbavAoalC1PYyE=
|
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
|
||||||
go.uber.org/atomic v1.9.0/go.mod h1:fEN4uk6kAWBTFdckzkM89CLk9XfWZrxpCo0nPH17wJc=
|
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
|
||||||
go.uber.org/multierr v1.9.0 h1:7fIwc/ZtS0q++VgcfqFDxSBZVv/Xo49/SYnDFupUwlI=
|
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
|
||||||
go.uber.org/multierr v1.9.0/go.mod h1:X2jQV1h+kxSjClGpnseKVIxpmcjrj7MNnI0bnlfKTVQ=
|
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
|
||||||
golang.org/x/arch v0.0.0-20210923205945-b76863e36670/go.mod h1:5om86z9Hs0C8fWVUuoMHwpExlXzs5Tkyp9hOrfG7pp8=
|
|
||||||
golang.org/x/arch v0.8.0 h1:3wRIsP3pM4yUptoR96otTUOXI367OS0+c9eeRi9doIc=
|
|
||||||
golang.org/x/arch v0.8.0/go.mod h1:FEVrYAQjsQXMVJ1nsMoVVXPZg6p2JE2mx8psSWTDQys=
|
|
||||||
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
|
||||||
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
|
||||||
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
|
||||||
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
golang.org/x/crypto v0.19.0/go.mod h1:Iy9bg/ha4yyC70EfRS8jz+B6ybOBKMaSxLj6P6oBDfU=
|
||||||
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
golang.org/x/crypto v0.23.0/go.mod h1:CKFgDieR+mRhux2Lsu27y0fO304Db0wZe70UKqHu0v8=
|
||||||
golang.org/x/crypto v0.36.0 h1:AnAEvhDddvBdpY+uR+MyHmuZzzNqXSe/GvuDeob5L34=
|
golang.org/x/crypto v0.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
|
||||||
golang.org/x/crypto v0.36.0/go.mod h1:Y4J0ReaxCR1IMaabaSMugxJES1EpwhBHhv2bDHklZvc=
|
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9 h1:GoHiUyI/Tp2nVkLI2mCxVkOjsbSXD66ic0XW0js0R9g=
|
|
||||||
golang.org/x/exp v0.0.0-20230905200255-921286631fa9/go.mod h1:S2oDrQGGwySpoQPVqRShND87VCbxmc6bL1Yd2oYrm6k=
|
|
||||||
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
|
||||||
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
|
||||||
@@ -173,8 +176,8 @@ golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
|
|||||||
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
|
||||||
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
|
||||||
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
|
||||||
golang.org/x/net v0.33.0 h1:74SYHlV8BIgHIFC/LrYkOGIwL19eTYXQ5wc6TBuO36I=
|
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
|
||||||
golang.org/x/net v0.33.0/go.mod h1:HXLR5J+9DxmrqMwG9qjGCxZ+zKXxBru04zlTvWlWuN4=
|
golang.org/x/net v0.39.0/go.mod h1:X7NRbYVEA+ewNkCNyJ513WmMdQ3BineSwVtN2zD/d+E=
|
||||||
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20190423024810-112230192c58/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.0.0-20220722155255-886fb9371eb4/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
golang.org/x/sync v0.1.0/go.mod h1:RxMgew5VJxzue5/jJTE5uejpjVlOe/izrB70Jof72aM=
|
||||||
@@ -190,17 +193,17 @@ golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/go.mod h1:oPkhp1MJrh7nUepCBc
|
|||||||
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220520151302-bc2c85ada10a/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.0.0-20220722155257-8c9f86f7a55f/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
|
golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.11.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
|
||||||
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||||
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||||
golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik=
|
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
|
||||||
golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
golang.org/x/sys v0.32.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||||
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
golang.org/x/telemetry v0.0.0-20240228155512-f48c80bd79b2/go.mod h1:TeRTkGYfJXctD9OcfyVLyj2J3IxLnKwHJR8f4D8a3YE=
|
||||||
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
|
||||||
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
golang.org/x/term v0.0.0-20210927222741-03fcf44c2211/go.mod h1:jbD1KX2456YbFQfuXm/mYQcufACuNUgVhRMnK/tPxf8=
|
||||||
@@ -210,8 +213,8 @@ golang.org/x/term v0.12.0/go.mod h1:owVbMEjm3cBLCHdkQu9b1opXd4ETQWc3BhuQGKgXgvU=
|
|||||||
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
golang.org/x/term v0.17.0/go.mod h1:lLRBjIVuehSbZlaOtGMbcMncT+aqLLLmKrsjNrUguwk=
|
||||||
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
golang.org/x/term v0.20.0/go.mod h1:8UkIAJTvZgivsXaD6/pH6U9ecQzZ45awqEOzuCvwpFY=
|
||||||
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
golang.org/x/term v0.28.0/go.mod h1:Sw/lC2IAUZ92udQNf3WodGtn4k/XoLyZoh8v/8uiwek=
|
||||||
golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y=
|
golang.org/x/term v0.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
|
||||||
golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g=
|
golang.org/x/term v0.31.0/go.mod h1:R4BeIy7D95HzImkxGkTW1UQTtP54tio2RyHz7PwK0aw=
|
||||||
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
|
||||||
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
golang.org/x/text v0.3.3/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
|
||||||
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
|
||||||
@@ -221,8 +224,8 @@ golang.org/x/text v0.13.0/go.mod h1:TvPlkZtksWOMsz7fbANvkp4WM8x/WCo/om8BMLbz+aE=
|
|||||||
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
golang.org/x/text v0.15.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU=
|
||||||
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
golang.org/x/text v0.21.0/go.mod h1:4IBbMaMmOPCJ8SecivzSH54+73PCFmPWxNTLm+vZkEQ=
|
||||||
golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY=
|
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
|
||||||
golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4=
|
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
|
||||||
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ=
|
||||||
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo=
|
||||||
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
golang.org/x/tools v0.1.12/go.mod h1:hNGJHUnrk76NpqgfD5Aqm5Crs+Hm0VOH/i9J2+nxYbc=
|
||||||
@@ -230,16 +233,15 @@ golang.org/x/tools v0.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
|
|||||||
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
|
||||||
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
|
||||||
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
||||||
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
|
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
|
||||||
google.golang.org/protobuf v1.36.5 h1:tPhr+woSbjfYvY6/GPufUoYizxw1cF/yFoxJ2fmpwlM=
|
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
|
||||||
google.golang.org/protobuf v1.36.5/go.mod h1:9fA7Ob0pmnwhb644+1+CVWFRbNajQ6iRojtC/QF5bRE=
|
|
||||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
|
||||||
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||||
gopkg.in/ini.v1 v1.67.0 h1:Dgnx+6+nfE+IfzjUEISNeydPJh9AXNNsWbGP9KzCsOA=
|
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
|
||||||
gopkg.in/ini.v1 v1.67.0/go.mod h1:pNLf8WUiyNEtQjuu5G5vTm06TEv9tsIgeAvK8hOrP4k=
|
|
||||||
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||||
|
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
|
||||||
|
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
|
||||||
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
|
||||||
rsc.io/pdf v0.1.1/go.mod h1:n8OzWcQ6Sp37PL01nO98y4iUCRdTGarVfzxY20ICaU4=
|
|
||||||
|
|||||||
@@ -2,13 +2,17 @@ package chatlog
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"runtime"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
||||||
"github.com/sjzar/chatlog/internal/ui/footer"
|
"github.com/sjzar/chatlog/internal/ui/footer"
|
||||||
|
"github.com/sjzar/chatlog/internal/ui/form"
|
||||||
"github.com/sjzar/chatlog/internal/ui/help"
|
"github.com/sjzar/chatlog/internal/ui/help"
|
||||||
"github.com/sjzar/chatlog/internal/ui/infobar"
|
"github.com/sjzar/chatlog/internal/ui/infobar"
|
||||||
"github.com/sjzar/chatlog/internal/ui/menu"
|
"github.com/sjzar/chatlog/internal/ui/menu"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat"
|
||||||
|
|
||||||
"github.com/gdamore/tcell/v2"
|
"github.com/gdamore/tcell/v2"
|
||||||
"github.com/rivo/tview"
|
"github.com/rivo/tview"
|
||||||
@@ -53,6 +57,8 @@ func NewApp(ctx *ctx.Context, m *Manager) *App {
|
|||||||
|
|
||||||
app.initMenu()
|
app.initMenu()
|
||||||
|
|
||||||
|
app.updateMenuItemsState()
|
||||||
|
|
||||||
return app
|
return app
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -90,6 +96,33 @@ func (a *App) Stop() {
|
|||||||
a.Application.Stop()
|
a.Application.Stop()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) updateMenuItemsState() {
|
||||||
|
// 查找并更新自动解密菜单项
|
||||||
|
for _, item := range a.menu.GetItems() {
|
||||||
|
// 更新自动解密菜单项
|
||||||
|
if item.Index == 5 {
|
||||||
|
if a.ctx.AutoDecrypt {
|
||||||
|
item.Name = "停止自动解密"
|
||||||
|
item.Description = "停止监控数据目录更新,不再自动解密新增数据"
|
||||||
|
} else {
|
||||||
|
item.Name = "开启自动解密"
|
||||||
|
item.Description = "监控数据目录更新,自动解密新增数据"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新HTTP服务菜单项
|
||||||
|
if item.Index == 4 {
|
||||||
|
if a.ctx.HTTPEnabled {
|
||||||
|
item.Name = "停止 HTTP 服务"
|
||||||
|
item.Description = "停止本地 HTTP & MCP 服务器"
|
||||||
|
} else {
|
||||||
|
item.Name = "启动 HTTP 服务"
|
||||||
|
item.Description = "启动本地 HTTP & MCP 服务器"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) switchTab(step int) {
|
func (a *App) switchTab(step int) {
|
||||||
index := (a.activeTab + step) % a.tabCount
|
index := (a.activeTab + step) % a.tabCount
|
||||||
if index < 0 {
|
if index < 0 {
|
||||||
@@ -108,17 +141,29 @@ func (a *App) refresh() {
|
|||||||
case <-a.stopRefresh:
|
case <-a.stopRefresh:
|
||||||
return
|
return
|
||||||
case <-tick.C:
|
case <-tick.C:
|
||||||
|
if a.ctx.AutoDecrypt || a.ctx.HTTPEnabled {
|
||||||
|
a.m.RefreshSession()
|
||||||
|
}
|
||||||
a.infoBar.UpdateAccount(a.ctx.Account)
|
a.infoBar.UpdateAccount(a.ctx.Account)
|
||||||
a.infoBar.UpdateBasicInfo(a.ctx.PID, a.ctx.Version, a.ctx.ExePath)
|
a.infoBar.UpdateBasicInfo(a.ctx.PID, a.ctx.FullVersion, a.ctx.ExePath)
|
||||||
a.infoBar.UpdateStatus(a.ctx.Status)
|
a.infoBar.UpdateStatus(a.ctx.Status)
|
||||||
a.infoBar.UpdateDataKey(a.ctx.DataKey)
|
a.infoBar.UpdateDataKey(a.ctx.DataKey)
|
||||||
|
a.infoBar.UpdatePlatform(a.ctx.Platform)
|
||||||
a.infoBar.UpdateDataUsageDir(a.ctx.DataUsage, a.ctx.DataDir)
|
a.infoBar.UpdateDataUsageDir(a.ctx.DataUsage, a.ctx.DataDir)
|
||||||
a.infoBar.UpdateWorkUsageDir(a.ctx.WorkUsage, a.ctx.WorkDir)
|
a.infoBar.UpdateWorkUsageDir(a.ctx.WorkUsage, a.ctx.WorkDir)
|
||||||
|
if a.ctx.LastSession.Unix() > 1000000000 {
|
||||||
|
a.infoBar.UpdateSession(a.ctx.LastSession.Format("2006-01-02 15:04:05"))
|
||||||
|
}
|
||||||
if a.ctx.HTTPEnabled {
|
if a.ctx.HTTPEnabled {
|
||||||
a.infoBar.UpdateHTTPServer(fmt.Sprintf("[green][已启动][white] [%s]", a.ctx.HTTPAddr))
|
a.infoBar.UpdateHTTPServer(fmt.Sprintf("[green][已启动][white] [%s]", a.ctx.HTTPAddr))
|
||||||
} else {
|
} else {
|
||||||
a.infoBar.UpdateHTTPServer("[未启动]")
|
a.infoBar.UpdateHTTPServer("[未启动]")
|
||||||
}
|
}
|
||||||
|
if a.ctx.AutoDecrypt {
|
||||||
|
a.infoBar.UpdateAutoDecrypt("[green][已开启][white]")
|
||||||
|
} else {
|
||||||
|
a.infoBar.UpdateAutoDecrypt("[未开启]")
|
||||||
|
}
|
||||||
|
|
||||||
a.Draw()
|
a.Draw()
|
||||||
}
|
}
|
||||||
@@ -159,11 +204,36 @@ func (a *App) initMenu() {
|
|||||||
Name: "获取数据密钥",
|
Name: "获取数据密钥",
|
||||||
Description: "从进程获取数据密钥",
|
Description: "从进程获取数据密钥",
|
||||||
Selected: func(i *menu.Item) {
|
Selected: func(i *menu.Item) {
|
||||||
if err := a.m.GetDataKey(); err != nil {
|
modal := tview.NewModal()
|
||||||
a.showError(err)
|
if runtime.GOOS == "darwin" {
|
||||||
return
|
modal.SetText("获取数据密钥中...\n预计需要 20 秒左右的时间,期间微信会卡住,请耐心等待")
|
||||||
|
} else {
|
||||||
|
modal.SetText("获取数据密钥中...")
|
||||||
}
|
}
|
||||||
a.showInfo("获取数据密钥成功")
|
a.mainPages.AddPage("modal", modal, true, true)
|
||||||
|
a.SetFocus(modal)
|
||||||
|
|
||||||
|
go func() {
|
||||||
|
err := a.m.GetDataKey()
|
||||||
|
|
||||||
|
// 在主线程中更新UI
|
||||||
|
a.QueueUpdateDraw(func() {
|
||||||
|
if err != nil {
|
||||||
|
// 解密失败
|
||||||
|
modal.SetText("获取数据密钥失败: " + err.Error())
|
||||||
|
} else {
|
||||||
|
// 解密成功
|
||||||
|
modal.SetText("获取数据密钥成功")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加确认按钮
|
||||||
|
modal.AddButtons([]string{"OK"})
|
||||||
|
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
a.mainPages.RemovePage("modal")
|
||||||
|
})
|
||||||
|
a.SetFocus(modal)
|
||||||
|
})
|
||||||
|
}()
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -231,11 +301,11 @@ func (a *App) initMenu() {
|
|||||||
} else {
|
} else {
|
||||||
// 启动成功
|
// 启动成功
|
||||||
modal.SetText("已启动 HTTP 服务")
|
modal.SetText("已启动 HTTP 服务")
|
||||||
// 更改菜单项名称
|
|
||||||
i.Name = "停止 HTTP 服务"
|
|
||||||
i.Description = "停止本地 HTTP & MCP 服务器"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更改菜单项名称
|
||||||
|
a.updateMenuItemsState()
|
||||||
|
|
||||||
// 添加确认按钮
|
// 添加确认按钮
|
||||||
modal.AddButtons([]string{"OK"})
|
modal.AddButtons([]string{"OK"})
|
||||||
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
@@ -262,11 +332,89 @@ func (a *App) initMenu() {
|
|||||||
} else {
|
} else {
|
||||||
// 停止成功
|
// 停止成功
|
||||||
modal.SetText("已停止 HTTP 服务")
|
modal.SetText("已停止 HTTP 服务")
|
||||||
// 更改菜单项名称
|
|
||||||
i.Name = "启动 HTTP 服务"
|
|
||||||
i.Description = "启动本地 HTTP 服务器"
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 更改菜单项名称
|
||||||
|
a.updateMenuItemsState()
|
||||||
|
|
||||||
|
// 添加确认按钮
|
||||||
|
modal.AddButtons([]string{"OK"})
|
||||||
|
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
a.mainPages.RemovePage("modal")
|
||||||
|
})
|
||||||
|
a.SetFocus(modal)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
autoDecrypt := &menu.Item{
|
||||||
|
Index: 5,
|
||||||
|
Name: "开启自动解密",
|
||||||
|
Description: "自动解密新增的数据文件",
|
||||||
|
Selected: func(i *menu.Item) {
|
||||||
|
modal := tview.NewModal()
|
||||||
|
|
||||||
|
// 根据当前自动解密状态执行不同操作
|
||||||
|
if !a.ctx.AutoDecrypt {
|
||||||
|
// 自动解密未开启,开启自动解密
|
||||||
|
modal.SetText("正在开启自动解密...")
|
||||||
|
a.mainPages.AddPage("modal", modal, true, true)
|
||||||
|
a.SetFocus(modal)
|
||||||
|
|
||||||
|
// 在后台开启自动解密
|
||||||
|
go func() {
|
||||||
|
err := a.m.StartAutoDecrypt()
|
||||||
|
|
||||||
|
// 在主线程中更新UI
|
||||||
|
a.QueueUpdateDraw(func() {
|
||||||
|
if err != nil {
|
||||||
|
// 开启失败
|
||||||
|
modal.SetText("开启自动解密失败: " + err.Error())
|
||||||
|
} else {
|
||||||
|
// 开启成功
|
||||||
|
if a.ctx.Version == 3 {
|
||||||
|
modal.SetText("已开启自动解密\n3.x版本数据文件更新不及时,有低延迟需求请使用4.0版本")
|
||||||
|
} else {
|
||||||
|
modal.SetText("已开启自动解密")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更改菜单项名称
|
||||||
|
a.updateMenuItemsState()
|
||||||
|
|
||||||
|
// 添加确认按钮
|
||||||
|
modal.AddButtons([]string{"OK"})
|
||||||
|
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
|
a.mainPages.RemovePage("modal")
|
||||||
|
})
|
||||||
|
a.SetFocus(modal)
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
} else {
|
||||||
|
// 自动解密已开启,停止自动解密
|
||||||
|
modal.SetText("正在停止自动解密...")
|
||||||
|
a.mainPages.AddPage("modal", modal, true, true)
|
||||||
|
a.SetFocus(modal)
|
||||||
|
|
||||||
|
// 在后台停止自动解密
|
||||||
|
go func() {
|
||||||
|
err := a.m.StopAutoDecrypt()
|
||||||
|
|
||||||
|
// 在主线程中更新UI
|
||||||
|
a.QueueUpdateDraw(func() {
|
||||||
|
if err != nil {
|
||||||
|
// 停止失败
|
||||||
|
modal.SetText("停止自动解密失败: " + err.Error())
|
||||||
|
} else {
|
||||||
|
// 停止成功
|
||||||
|
modal.SetText("已停止自动解密")
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更改菜单项名称
|
||||||
|
a.updateMenuItemsState()
|
||||||
|
|
||||||
// 添加确认按钮
|
// 添加确认按钮
|
||||||
modal.AddButtons([]string{"OK"})
|
modal.AddButtons([]string{"OK"})
|
||||||
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
|
||||||
@@ -280,19 +428,28 @@ func (a *App) initMenu() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setting := &menu.Item{
|
setting := &menu.Item{
|
||||||
Index: 5,
|
Index: 6,
|
||||||
Name: "设置",
|
Name: "设置",
|
||||||
Description: "设置应用程序选项",
|
Description: "设置应用程序选项",
|
||||||
Selected: a.settingSelected,
|
Selected: a.settingSelected,
|
||||||
}
|
}
|
||||||
|
|
||||||
a.menu.AddItem(setting)
|
selectAccount := &menu.Item{
|
||||||
|
Index: 7,
|
||||||
|
Name: "切换账号",
|
||||||
|
Description: "切换当前操作的账号,可以选择进程或历史账号",
|
||||||
|
Selected: a.selectAccountSelected,
|
||||||
|
}
|
||||||
|
|
||||||
a.menu.AddItem(getDataKey)
|
a.menu.AddItem(getDataKey)
|
||||||
a.menu.AddItem(decryptData)
|
a.menu.AddItem(decryptData)
|
||||||
a.menu.AddItem(httpServer)
|
a.menu.AddItem(httpServer)
|
||||||
|
a.menu.AddItem(autoDecrypt)
|
||||||
|
a.menu.AddItem(setting)
|
||||||
|
a.menu.AddItem(selectAccount)
|
||||||
|
|
||||||
a.menu.AddItem(&menu.Item{
|
a.menu.AddItem(&menu.Item{
|
||||||
Index: 6,
|
Index: 8,
|
||||||
Name: "退出",
|
Name: "退出",
|
||||||
Description: "退出程序",
|
Description: "退出程序",
|
||||||
Selected: func(i *menu.Item) {
|
Selected: func(i *menu.Item) {
|
||||||
@@ -312,8 +469,8 @@ func (a *App) settingSelected(i *menu.Item) {
|
|||||||
|
|
||||||
settings := []settingItem{
|
settings := []settingItem{
|
||||||
{
|
{
|
||||||
name: "设置 HTTP 服务端口",
|
name: "设置 HTTP 服务地址",
|
||||||
description: "配置 HTTP 服务监听的端口",
|
description: "配置 HTTP 服务监听的地址",
|
||||||
action: a.settingHTTPPort,
|
action: a.settingHTTPPort,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
@@ -321,6 +478,16 @@ func (a *App) settingSelected(i *menu.Item) {
|
|||||||
description: "配置数据解密后的存储目录",
|
description: "配置数据解密后的存储目录",
|
||||||
action: a.settingWorkDir,
|
action: a.settingWorkDir,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
name: "设置数据密钥",
|
||||||
|
description: "配置数据解密密钥",
|
||||||
|
action: a.settingDataKey,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "设置数据目录",
|
||||||
|
description: "配置微信数据文件所在目录",
|
||||||
|
action: a.settingDataDir,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
subMenu := menu.NewSubMenu("设置")
|
subMenu := menu.NewSubMenu("设置")
|
||||||
@@ -344,43 +511,279 @@ func (a *App) settingSelected(i *menu.Item) {
|
|||||||
|
|
||||||
// settingHTTPPort 设置 HTTP 端口
|
// settingHTTPPort 设置 HTTP 端口
|
||||||
func (a *App) settingHTTPPort() {
|
func (a *App) settingHTTPPort() {
|
||||||
// 实现端口设置逻辑
|
// 使用我们的自定义表单组件
|
||||||
// 这里可以使用 tview.InputField 让用户输入端口
|
formView := form.NewForm("设置 HTTP 地址")
|
||||||
form := tview.NewForm().
|
|
||||||
AddInputField("端口", a.ctx.HTTPAddr, 20, nil, func(text string) {
|
|
||||||
a.ctx.SetHTTPAddr(text)
|
|
||||||
}).
|
|
||||||
AddButton("保存", func() {
|
|
||||||
a.mainPages.RemovePage("submenu2")
|
|
||||||
a.showInfo("HTTP 端口已设置为 " + a.ctx.HTTPAddr)
|
|
||||||
}).
|
|
||||||
AddButton("取消", func() {
|
|
||||||
a.mainPages.RemovePage("submenu2")
|
|
||||||
})
|
|
||||||
form.SetBorder(true).SetTitle("设置 HTTP 端口")
|
|
||||||
|
|
||||||
a.mainPages.AddPage("submenu2", form, true, true)
|
// 临时存储用户输入的值
|
||||||
a.SetFocus(form)
|
tempHTTPAddr := a.ctx.HTTPAddr
|
||||||
|
|
||||||
|
// 添加输入字段 - 不再直接设置HTTP地址,而是更新临时变量
|
||||||
|
formView.AddInputField("地址", tempHTTPAddr, 0, nil, func(text string) {
|
||||||
|
tempHTTPAddr = text // 只更新临时变量
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加按钮 - 点击保存时才设置HTTP地址
|
||||||
|
formView.AddButton("保存", func() {
|
||||||
|
a.m.SetHTTPAddr(tempHTTPAddr) // 在这里设置HTTP地址
|
||||||
|
a.mainPages.RemovePage("submenu2")
|
||||||
|
a.showInfo("HTTP 地址已设置为 " + a.ctx.HTTPAddr)
|
||||||
|
})
|
||||||
|
|
||||||
|
formView.AddButton("取消", func() {
|
||||||
|
a.mainPages.RemovePage("submenu2")
|
||||||
|
})
|
||||||
|
|
||||||
|
a.mainPages.AddPage("submenu2", formView, true, true)
|
||||||
|
a.SetFocus(formView)
|
||||||
}
|
}
|
||||||
|
|
||||||
// settingWorkDir 设置工作目录
|
// settingWorkDir 设置工作目录
|
||||||
func (a *App) settingWorkDir() {
|
func (a *App) settingWorkDir() {
|
||||||
// 实现工作目录设置逻辑
|
// 使用我们的自定义表单组件
|
||||||
form := tview.NewForm().
|
formView := form.NewForm("设置工作目录")
|
||||||
AddInputField("工作目录", a.ctx.WorkDir, 40, nil, func(text string) {
|
|
||||||
a.ctx.SetWorkDir(text)
|
|
||||||
}).
|
|
||||||
AddButton("保存", func() {
|
|
||||||
a.mainPages.RemovePage("submenu2")
|
|
||||||
a.showInfo("工作目录已设置为 " + a.ctx.WorkDir)
|
|
||||||
}).
|
|
||||||
AddButton("取消", func() {
|
|
||||||
a.mainPages.RemovePage("submenu2")
|
|
||||||
})
|
|
||||||
form.SetBorder(true).SetTitle("设置工作目录")
|
|
||||||
|
|
||||||
a.mainPages.AddPage("submenu2", form, true, true)
|
// 临时存储用户输入的值
|
||||||
a.SetFocus(form)
|
tempWorkDir := a.ctx.WorkDir
|
||||||
|
|
||||||
|
// 添加输入字段 - 不再直接设置工作目录,而是更新临时变量
|
||||||
|
formView.AddInputField("工作目录", tempWorkDir, 0, nil, func(text string) {
|
||||||
|
tempWorkDir = text // 只更新临时变量
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加按钮 - 点击保存时才设置工作目录
|
||||||
|
formView.AddButton("保存", func() {
|
||||||
|
a.ctx.SetWorkDir(tempWorkDir) // 在这里设置工作目录
|
||||||
|
a.mainPages.RemovePage("submenu2")
|
||||||
|
a.showInfo("工作目录已设置为 " + a.ctx.WorkDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
formView.AddButton("取消", func() {
|
||||||
|
a.mainPages.RemovePage("submenu2")
|
||||||
|
})
|
||||||
|
|
||||||
|
a.mainPages.AddPage("submenu2", formView, true, true)
|
||||||
|
a.SetFocus(formView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingDataKey 设置数据密钥
|
||||||
|
func (a *App) settingDataKey() {
|
||||||
|
// 使用我们的自定义表单组件
|
||||||
|
formView := form.NewForm("设置数据密钥")
|
||||||
|
|
||||||
|
// 临时存储用户输入的值
|
||||||
|
tempDataKey := a.ctx.DataKey
|
||||||
|
|
||||||
|
// 添加输入字段 - 不直接设置数据密钥,而是更新临时变量
|
||||||
|
formView.AddInputField("数据密钥", tempDataKey, 0, nil, func(text string) {
|
||||||
|
tempDataKey = text // 只更新临时变量
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加按钮 - 点击保存时才设置数据密钥
|
||||||
|
formView.AddButton("保存", func() {
|
||||||
|
a.ctx.DataKey = tempDataKey // 设置数据密钥
|
||||||
|
a.mainPages.RemovePage("submenu2")
|
||||||
|
a.showInfo("数据密钥已设置")
|
||||||
|
})
|
||||||
|
|
||||||
|
formView.AddButton("取消", func() {
|
||||||
|
a.mainPages.RemovePage("submenu2")
|
||||||
|
})
|
||||||
|
|
||||||
|
a.mainPages.AddPage("submenu2", formView, true, true)
|
||||||
|
a.SetFocus(formView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// settingDataDir 设置数据目录
|
||||||
|
func (a *App) settingDataDir() {
|
||||||
|
// 使用我们的自定义表单组件
|
||||||
|
formView := form.NewForm("设置数据目录")
|
||||||
|
|
||||||
|
// 临时存储用户输入的值
|
||||||
|
tempDataDir := a.ctx.DataDir
|
||||||
|
|
||||||
|
// 添加输入字段 - 不直接设置数据目录,而是更新临时变量
|
||||||
|
formView.AddInputField("数据目录", tempDataDir, 0, nil, func(text string) {
|
||||||
|
tempDataDir = text // 只更新临时变量
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加按钮 - 点击保存时才设置数据目录
|
||||||
|
formView.AddButton("保存", func() {
|
||||||
|
a.ctx.DataDir = tempDataDir // 设置数据目录
|
||||||
|
a.mainPages.RemovePage("submenu2")
|
||||||
|
a.showInfo("数据目录已设置为 " + a.ctx.DataDir)
|
||||||
|
})
|
||||||
|
|
||||||
|
formView.AddButton("取消", func() {
|
||||||
|
a.mainPages.RemovePage("submenu2")
|
||||||
|
})
|
||||||
|
|
||||||
|
a.mainPages.AddPage("submenu2", formView, true, true)
|
||||||
|
a.SetFocus(formView)
|
||||||
|
}
|
||||||
|
|
||||||
|
// selectAccountSelected 处理切换账号菜单项的选择事件
|
||||||
|
func (a *App) selectAccountSelected(i *menu.Item) {
|
||||||
|
// 创建子菜单
|
||||||
|
subMenu := menu.NewSubMenu("切换账号")
|
||||||
|
|
||||||
|
// 添加微信进程
|
||||||
|
instances := a.m.wechat.GetWeChatInstances()
|
||||||
|
if len(instances) > 0 {
|
||||||
|
// 添加实例标题
|
||||||
|
subMenu.AddItem(&menu.Item{
|
||||||
|
Index: 0,
|
||||||
|
Name: "--- 微信进程 ---",
|
||||||
|
Description: "",
|
||||||
|
Hidden: false,
|
||||||
|
Selected: nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加实例列表
|
||||||
|
for idx, instance := range instances {
|
||||||
|
// 创建一个实例描述
|
||||||
|
description := fmt.Sprintf("版本: %s 目录: %s", instance.FullVersion, instance.DataDir)
|
||||||
|
|
||||||
|
// 标记当前选中的实例
|
||||||
|
name := fmt.Sprintf("%s [%d]", instance.Name, instance.PID)
|
||||||
|
if a.ctx.Current != nil && a.ctx.Current.PID == instance.PID {
|
||||||
|
name = name + " [当前]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建菜单项
|
||||||
|
instanceItem := &menu.Item{
|
||||||
|
Index: idx + 1,
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Hidden: false,
|
||||||
|
Selected: func(instance *wechat.Account) func(*menu.Item) {
|
||||||
|
return func(*menu.Item) {
|
||||||
|
// 如果是当前账号,则无需切换
|
||||||
|
if a.ctx.Current != nil && a.ctx.Current.PID == instance.PID {
|
||||||
|
a.mainPages.RemovePage("submenu")
|
||||||
|
a.showInfo("已经是当前账号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示切换中的模态框
|
||||||
|
modal := tview.NewModal().SetText("正在切换账号...")
|
||||||
|
a.mainPages.AddPage("modal", modal, true, true)
|
||||||
|
a.SetFocus(modal)
|
||||||
|
|
||||||
|
// 在后台执行切换操作
|
||||||
|
go func() {
|
||||||
|
err := a.m.Switch(instance, "")
|
||||||
|
|
||||||
|
// 在主线程中更新UI
|
||||||
|
a.QueueUpdateDraw(func() {
|
||||||
|
a.mainPages.RemovePage("modal")
|
||||||
|
a.mainPages.RemovePage("submenu")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// 切换失败
|
||||||
|
a.showError(fmt.Errorf("切换账号失败: %v", err))
|
||||||
|
} else {
|
||||||
|
// 切换成功
|
||||||
|
a.showInfo("切换账号成功")
|
||||||
|
// 更新菜单状态
|
||||||
|
a.updateMenuItemsState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}(instance),
|
||||||
|
}
|
||||||
|
subMenu.AddItem(instanceItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加历史账号
|
||||||
|
if len(a.ctx.History) > 0 {
|
||||||
|
// 添加历史账号标题
|
||||||
|
subMenu.AddItem(&menu.Item{
|
||||||
|
Index: 100,
|
||||||
|
Name: "--- 历史账号 ---",
|
||||||
|
Description: "",
|
||||||
|
Hidden: false,
|
||||||
|
Selected: nil,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加历史账号列表
|
||||||
|
idx := 101
|
||||||
|
for account, hist := range a.ctx.History {
|
||||||
|
// 创建一个账号描述
|
||||||
|
description := fmt.Sprintf("版本: %s 目录: %s", hist.FullVersion, hist.DataDir)
|
||||||
|
|
||||||
|
// 标记当前选中的账号
|
||||||
|
name := account
|
||||||
|
if name == "" {
|
||||||
|
name = filepath.Base(hist.DataDir)
|
||||||
|
}
|
||||||
|
if a.ctx.DataDir == hist.DataDir {
|
||||||
|
name = name + " [当前]"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建菜单项
|
||||||
|
histItem := &menu.Item{
|
||||||
|
Index: idx,
|
||||||
|
Name: name,
|
||||||
|
Description: description,
|
||||||
|
Hidden: false,
|
||||||
|
Selected: func(account string) func(*menu.Item) {
|
||||||
|
return func(*menu.Item) {
|
||||||
|
// 如果是当前账号,则无需切换
|
||||||
|
if a.ctx.Current != nil && a.ctx.DataDir == a.ctx.History[account].DataDir {
|
||||||
|
a.mainPages.RemovePage("submenu")
|
||||||
|
a.showInfo("已经是当前账号")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示切换中的模态框
|
||||||
|
modal := tview.NewModal().SetText("正在切换账号...")
|
||||||
|
a.mainPages.AddPage("modal", modal, true, true)
|
||||||
|
a.SetFocus(modal)
|
||||||
|
|
||||||
|
// 在后台执行切换操作
|
||||||
|
go func() {
|
||||||
|
err := a.m.Switch(nil, account)
|
||||||
|
|
||||||
|
// 在主线程中更新UI
|
||||||
|
a.QueueUpdateDraw(func() {
|
||||||
|
a.mainPages.RemovePage("modal")
|
||||||
|
a.mainPages.RemovePage("submenu")
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
// 切换失败
|
||||||
|
a.showError(fmt.Errorf("切换账号失败: %v", err))
|
||||||
|
} else {
|
||||||
|
// 切换成功
|
||||||
|
a.showInfo("切换账号成功")
|
||||||
|
// 更新菜单状态
|
||||||
|
a.updateMenuItemsState()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}(account),
|
||||||
|
}
|
||||||
|
idx++
|
||||||
|
subMenu.AddItem(histItem)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有账号可选择
|
||||||
|
if len(a.ctx.History) == 0 && len(instances) == 0 {
|
||||||
|
subMenu.AddItem(&menu.Item{
|
||||||
|
Index: 1,
|
||||||
|
Name: "无可用账号",
|
||||||
|
Description: "未检测到微信进程或历史账号",
|
||||||
|
Hidden: false,
|
||||||
|
Selected: nil,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 显示子菜单
|
||||||
|
a.mainPages.AddPage("submenu", subMenu, true, true)
|
||||||
|
a.SetFocus(subMenu)
|
||||||
}
|
}
|
||||||
|
|
||||||
// showModal 显示一个模态对话框
|
// showModal 显示一个模态对话框
|
||||||
|
|||||||
@@ -9,17 +9,18 @@ type Config struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProcessConfig struct {
|
type ProcessConfig struct {
|
||||||
Type string `mapstructure:"type" json:"type"`
|
Type string `mapstructure:"type" json:"type"`
|
||||||
Version string `mapstructure:"version" json:"version"`
|
Account string `mapstructure:"account" json:"account"`
|
||||||
MajorVersion int `mapstructure:"major_version" json:"major_version"`
|
Platform string `mapstructure:"platform" json:"platform"`
|
||||||
Account string `mapstructure:"account" json:"account"`
|
Version int `mapstructure:"version" json:"version"`
|
||||||
DataKey string `mapstructure:"data_key" json:"data_key"`
|
FullVersion string `mapstructure:"full_version" json:"full_version"`
|
||||||
DataDir string `mapstructure:"data_dir" json:"data_dir"`
|
DataDir string `mapstructure:"data_dir" json:"data_dir"`
|
||||||
WorkDir string `mapstructure:"work_dir" json:"work_dir"`
|
DataKey string `mapstructure:"data_key" json:"data_key"`
|
||||||
HTTPEnabled bool `mapstructure:"http_enabled" json:"http_enabled"`
|
WorkDir string `mapstructure:"work_dir" json:"work_dir"`
|
||||||
HTTPAddr string `mapstructure:"http_addr" json:"http_addr"`
|
HTTPEnabled bool `mapstructure:"http_enabled" json:"http_enabled"`
|
||||||
LastTime int64 `mapstructure:"last_time" json:"last_time"`
|
HTTPAddr string `mapstructure:"http_addr" json:"http_addr"`
|
||||||
Files []File `mapstructure:"files" json:"files"`
|
LastTime int64 `mapstructure:"last_time" json:"last_time"`
|
||||||
|
Files []File `mapstructure:"files" json:"files"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type File struct {
|
type File struct {
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package ctx
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"sync"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/conf"
|
"github.com/sjzar/chatlog/internal/chatlog/conf"
|
||||||
"github.com/sjzar/chatlog/internal/wechat"
|
"github.com/sjzar/chatlog/internal/wechat"
|
||||||
@@ -17,29 +18,34 @@ type Context struct {
|
|||||||
History map[string]conf.ProcessConfig
|
History map[string]conf.ProcessConfig
|
||||||
|
|
||||||
// 微信账号相关状态
|
// 微信账号相关状态
|
||||||
Account string
|
Account string
|
||||||
Version string
|
Platform string
|
||||||
MajorVersion int
|
Version int
|
||||||
DataKey string
|
FullVersion string
|
||||||
DataUsage string
|
DataDir string
|
||||||
DataDir string
|
DataKey string
|
||||||
|
DataUsage string
|
||||||
|
|
||||||
// 工作目录相关状态
|
// 工作目录相关状态
|
||||||
WorkUsage string
|
|
||||||
WorkDir string
|
WorkDir string
|
||||||
|
WorkUsage string
|
||||||
|
|
||||||
// HTTP服务相关状态
|
// HTTP服务相关状态
|
||||||
HTTPEnabled bool
|
HTTPEnabled bool
|
||||||
HTTPAddr string
|
HTTPAddr string
|
||||||
|
|
||||||
|
// 自动解密
|
||||||
|
AutoDecrypt bool
|
||||||
|
LastSession time.Time
|
||||||
|
|
||||||
// 当前选中的微信实例
|
// 当前选中的微信实例
|
||||||
Current *wechat.Info
|
Current *wechat.Account
|
||||||
PID int
|
PID int
|
||||||
ExePath string
|
ExePath string
|
||||||
Status string
|
Status string
|
||||||
|
|
||||||
// 所有可用的微信实例
|
// 所有可用的微信实例
|
||||||
WeChatInstances []*wechat.Info
|
WeChatInstances []*wechat.Account
|
||||||
}
|
}
|
||||||
|
|
||||||
func New(conf *conf.Service) *Context {
|
func New(conf *conf.Service) *Context {
|
||||||
@@ -62,21 +68,36 @@ func (c *Context) loadConfig() {
|
|||||||
func (c *Context) SwitchHistory(account string) {
|
func (c *Context) SwitchHistory(account string) {
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
|
c.Current = nil
|
||||||
|
c.PID = 0
|
||||||
|
c.ExePath = ""
|
||||||
|
c.Status = ""
|
||||||
history, ok := c.History[account]
|
history, ok := c.History[account]
|
||||||
if ok {
|
if ok {
|
||||||
c.Account = history.Account
|
c.Account = history.Account
|
||||||
|
c.Platform = history.Platform
|
||||||
c.Version = history.Version
|
c.Version = history.Version
|
||||||
c.MajorVersion = history.MajorVersion
|
c.FullVersion = history.FullVersion
|
||||||
c.DataKey = history.DataKey
|
c.DataKey = history.DataKey
|
||||||
c.DataDir = history.DataDir
|
c.DataDir = history.DataDir
|
||||||
c.WorkDir = history.WorkDir
|
c.WorkDir = history.WorkDir
|
||||||
c.HTTPEnabled = history.HTTPEnabled
|
c.HTTPEnabled = history.HTTPEnabled
|
||||||
c.HTTPAddr = history.HTTPAddr
|
c.HTTPAddr = history.HTTPAddr
|
||||||
|
} else {
|
||||||
|
c.Account = ""
|
||||||
|
c.Platform = ""
|
||||||
|
c.Version = 0
|
||||||
|
c.FullVersion = ""
|
||||||
|
c.DataKey = ""
|
||||||
|
c.DataDir = ""
|
||||||
|
c.WorkDir = ""
|
||||||
|
c.HTTPEnabled = false
|
||||||
|
c.HTTPAddr = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (c *Context) SwitchCurrent(info *wechat.Info) {
|
func (c *Context) SwitchCurrent(info *wechat.Account) {
|
||||||
c.SwitchHistory(info.AccountName)
|
c.SwitchHistory(info.Name)
|
||||||
c.mu.Lock()
|
c.mu.Lock()
|
||||||
defer c.mu.Unlock()
|
defer c.mu.Unlock()
|
||||||
c.Current = info
|
c.Current = info
|
||||||
@@ -85,9 +106,10 @@ func (c *Context) SwitchCurrent(info *wechat.Info) {
|
|||||||
}
|
}
|
||||||
func (c *Context) Refresh() {
|
func (c *Context) Refresh() {
|
||||||
if c.Current != nil {
|
if c.Current != nil {
|
||||||
c.Account = c.Current.AccountName
|
c.Account = c.Current.Name
|
||||||
c.Version = c.Current.Version.FileVersion
|
c.Platform = c.Current.Platform
|
||||||
c.MajorVersion = c.Current.Version.FileMajorVersion
|
c.Version = c.Current.Version
|
||||||
|
c.FullVersion = c.Current.FullVersion
|
||||||
c.PID = int(c.Current.PID)
|
c.PID = int(c.Current.PID)
|
||||||
c.ExePath = c.Current.ExePath
|
c.ExePath = c.Current.ExePath
|
||||||
c.Status = c.Current.Status
|
c.Status = c.Current.Status
|
||||||
@@ -140,18 +162,26 @@ func (c *Context) SetDataDir(dir string) {
|
|||||||
c.Refresh()
|
c.Refresh()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (c *Context) SetAutoDecrypt(enabled bool) {
|
||||||
|
c.mu.Lock()
|
||||||
|
defer c.mu.Unlock()
|
||||||
|
c.AutoDecrypt = enabled
|
||||||
|
c.UpdateConfig()
|
||||||
|
}
|
||||||
|
|
||||||
// 更新配置
|
// 更新配置
|
||||||
func (c *Context) UpdateConfig() {
|
func (c *Context) UpdateConfig() {
|
||||||
pconf := conf.ProcessConfig{
|
pconf := conf.ProcessConfig{
|
||||||
Type: "wechat",
|
Type: "wechat",
|
||||||
Version: c.Version,
|
Account: c.Account,
|
||||||
MajorVersion: c.MajorVersion,
|
Platform: c.Platform,
|
||||||
Account: c.Account,
|
Version: c.Version,
|
||||||
DataKey: c.DataKey,
|
FullVersion: c.FullVersion,
|
||||||
DataDir: c.DataDir,
|
DataDir: c.DataDir,
|
||||||
WorkDir: c.WorkDir,
|
DataKey: c.DataKey,
|
||||||
HTTPEnabled: c.HTTPEnabled,
|
WorkDir: c.WorkDir,
|
||||||
HTTPAddr: c.HTTPAddr,
|
HTTPEnabled: c.HTTPEnabled,
|
||||||
|
HTTPAddr: c.HTTPAddr,
|
||||||
}
|
}
|
||||||
conf := c.conf.GetConfig()
|
conf := c.conf.GetConfig()
|
||||||
conf.UpdateHistory(c.Account, pconf)
|
conf.UpdateHistory(c.Account, pconf)
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
||||||
|
"github.com/sjzar/chatlog/internal/model"
|
||||||
"github.com/sjzar/chatlog/internal/wechatdb"
|
"github.com/sjzar/chatlog/internal/wechatdb"
|
||||||
"github.com/sjzar/chatlog/pkg/model"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -20,7 +20,7 @@ func NewService(ctx *ctx.Context) *Service {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Start() error {
|
func (s *Service) Start() error {
|
||||||
db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.MajorVersion)
|
db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.Platform, s.ctx.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -36,42 +36,33 @@ func (s *Service) Stop() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDB returns the underlying database
|
|
||||||
func (s *Service) GetDB() *wechatdb.DB {
|
func (s *Service) GetDB() *wechatdb.DB {
|
||||||
return s.db
|
return s.db
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetMessages retrieves messages based on criteria
|
func (s *Service) GetMessages(start, end time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
|
||||||
func (s *Service) GetMessages(start, end time.Time, talker string, limit, offset int) ([]*model.Message, error) {
|
return s.db.GetMessages(start, end, talker, sender, keyword, limit, offset)
|
||||||
return s.db.GetMessages(start, end, talker, limit, offset)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetContact retrieves contact information
|
func (s *Service) GetContacts(key string, limit, offset int) (*wechatdb.GetContactsResp, error) {
|
||||||
func (s *Service) GetContact(userName string) *model.Contact {
|
return s.db.GetContacts(key, limit, offset)
|
||||||
return s.db.GetContact(userName)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListContact retrieves all contacts
|
func (s *Service) GetChatRooms(key string, limit, offset int) (*wechatdb.GetChatRoomsResp, error) {
|
||||||
func (s *Service) ListContact() (*wechatdb.ListContactResp, error) {
|
return s.db.GetChatRooms(key, limit, offset)
|
||||||
return s.db.ListContact()
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetChatRoom retrieves chat room information
|
|
||||||
func (s *Service) GetChatRoom(name string) *model.ChatRoom {
|
|
||||||
return s.db.GetChatRoom(name)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ListChatRoom retrieves all chat rooms
|
|
||||||
func (s *Service) ListChatRoom() (*wechatdb.ListChatRoomResp, error) {
|
|
||||||
return s.db.ListChatRoom()
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetSession retrieves session information
|
// GetSession retrieves session information
|
||||||
func (s *Service) GetSession(limit int) (*wechatdb.GetSessionResp, error) {
|
func (s *Service) GetSessions(key string, limit, offset int) (*wechatdb.GetSessionsResp, error) {
|
||||||
return s.db.GetSession(limit)
|
return s.db.GetSessions(key, limit, offset)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetMedia(_type string, key string) (*model.Media, error) {
|
||||||
|
return s.db.GetMedia(_type, key)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Close closes the database connection
|
// Close closes the database connection
|
||||||
func (s *Service) Close() {
|
func (s *Service) Close() {
|
||||||
// Add cleanup code if needed
|
// Add cleanup code if needed
|
||||||
|
s.db.Close()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,11 +5,14 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/internal/errors"
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
"github.com/sjzar/chatlog/pkg/util"
|
"github.com/sjzar/chatlog/pkg/util"
|
||||||
|
"github.com/sjzar/chatlog/pkg/util/dat2img"
|
||||||
|
"github.com/sjzar/chatlog/pkg/util/silk"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
@@ -19,8 +22,6 @@ import (
|
|||||||
//go:embed static
|
//go:embed static
|
||||||
var EFS embed.FS
|
var EFS embed.FS
|
||||||
|
|
||||||
// initRouter sets up routes and static file servers for the web service.
|
|
||||||
// It defines endpoints for API as well as serving static content.
|
|
||||||
func (s *Service) initRouter() {
|
func (s *Service) initRouter() {
|
||||||
|
|
||||||
router := s.GetRouter()
|
router := s.GetRouter()
|
||||||
@@ -30,6 +31,13 @@ func (s *Service) initRouter() {
|
|||||||
router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir))
|
router.StaticFileFS("/favicon.ico", "./favicon.ico", http.FS(staticDir))
|
||||||
router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
|
router.StaticFileFS("/", "./index.htm", http.FS(staticDir))
|
||||||
|
|
||||||
|
// Media
|
||||||
|
router.GET("/image/*key", s.GetImage)
|
||||||
|
router.GET("/video/*key", s.GetVideo)
|
||||||
|
router.GET("/file/*key", s.GetFile)
|
||||||
|
router.GET("/voice/*key", s.GetVoice)
|
||||||
|
router.GET("/data/*path", s.GetMediaData)
|
||||||
|
|
||||||
// MCP Server
|
// MCP Server
|
||||||
{
|
{
|
||||||
router.GET("/sse", s.mcp.HandleSSE)
|
router.GET("/sse", s.mcp.HandleSSE)
|
||||||
@@ -43,9 +51,9 @@ func (s *Service) initRouter() {
|
|||||||
api := router.Group("/api/v1")
|
api := router.Group("/api/v1")
|
||||||
{
|
{
|
||||||
api.GET("/chatlog", s.GetChatlog)
|
api.GET("/chatlog", s.GetChatlog)
|
||||||
api.GET("/contact", s.ListContact)
|
api.GET("/contact", s.GetContacts)
|
||||||
api.GET("/chatroom", s.ListChatRoom)
|
api.GET("/chatroom", s.GetChatRooms)
|
||||||
api.GET("/session", s.GetSession)
|
api.GET("/session", s.GetSessions)
|
||||||
}
|
}
|
||||||
|
|
||||||
router.NoRoute(s.NoRoute)
|
router.NoRoute(s.NoRoute)
|
||||||
@@ -66,47 +74,41 @@ func (s *Service) NoRoute(c *gin.Context) {
|
|||||||
|
|
||||||
func (s *Service) GetChatlog(c *gin.Context) {
|
func (s *Service) GetChatlog(c *gin.Context) {
|
||||||
|
|
||||||
|
q := struct {
|
||||||
|
Time string `form:"time"`
|
||||||
|
Talker string `form:"talker"`
|
||||||
|
Sender string `form:"sender"`
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
Limit int `form:"limit"`
|
||||||
|
Offset int `form:"offset"`
|
||||||
|
Format string `form:"format"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
if err := c.BindQuery(&q); err != nil {
|
||||||
|
errors.Err(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
var err error
|
var err error
|
||||||
start, end, ok := util.TimeRangeOf(c.Query("time"))
|
start, end, ok := util.TimeRangeOf(q.Time)
|
||||||
if !ok {
|
if !ok {
|
||||||
errors.Err(c, errors.ErrInvalidArg("time"))
|
errors.Err(c, errors.InvalidArg("time"))
|
||||||
|
}
|
||||||
|
if q.Limit < 0 {
|
||||||
|
q.Limit = 0
|
||||||
}
|
}
|
||||||
|
|
||||||
var limit int
|
if q.Offset < 0 {
|
||||||
if _limit := c.Query("limit"); len(_limit) > 0 {
|
q.Offset = 0
|
||||||
limit, err = strconv.Atoi(_limit)
|
|
||||||
if err != nil {
|
|
||||||
errors.Err(c, errors.ErrInvalidArg("limit"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var offset int
|
messages, err := s.db.GetMessages(start, end, q.Talker, q.Sender, q.Keyword, q.Limit, q.Offset)
|
||||||
if _offset := c.Query("offset"); len(_offset) > 0 {
|
|
||||||
offset, err = strconv.Atoi(_offset)
|
|
||||||
if err != nil {
|
|
||||||
errors.Err(c, errors.ErrInvalidArg("offset"))
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
talker := c.Query("talker")
|
|
||||||
|
|
||||||
if limit < 0 {
|
|
||||||
limit = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
if offset < 0 {
|
|
||||||
offset = 0
|
|
||||||
}
|
|
||||||
|
|
||||||
messages, err := s.db.GetMessages(start, end, talker, limit, offset)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors.Err(c, err)
|
errors.Err(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
switch strings.ToLower(c.Query("format")) {
|
switch strings.ToLower(q.Format) {
|
||||||
case "csv":
|
case "csv":
|
||||||
case "json":
|
case "json":
|
||||||
// json
|
// json
|
||||||
@@ -119,21 +121,34 @@ func (s *Service) GetChatlog(c *gin.Context) {
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
|
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
c.Writer.WriteString(m.PlainText(len(talker) == 0))
|
c.Writer.WriteString(m.PlainText(strings.Contains(q.Talker, ","), util.PerfectTimeFormat(start, end), c.Request.Host))
|
||||||
c.Writer.WriteString("\n")
|
c.Writer.WriteString("\n")
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListContact(c *gin.Context) {
|
func (s *Service) GetContacts(c *gin.Context) {
|
||||||
list, err := s.db.ListContact()
|
|
||||||
|
q := struct {
|
||||||
|
Keyword string `form:"keyword"`
|
||||||
|
Limit int `form:"limit"`
|
||||||
|
Offset int `form:"offset"`
|
||||||
|
Format string `form:"format"`
|
||||||
|
}{}
|
||||||
|
|
||||||
|
if err := c.BindQuery(&q); err != nil {
|
||||||
|
errors.Err(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
list, err := s.db.GetContacts(q.Keyword, q.Limit, q.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors.Err(c, err)
|
errors.Err(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
format := strings.ToLower(c.Query("format"))
|
format := strings.ToLower(q.Format)
|
||||||
switch format {
|
switch format {
|
||||||
case "json":
|
case "json":
|
||||||
// json
|
// json
|
||||||
@@ -158,22 +173,26 @@ func (s *Service) ListContact(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) ListChatRoom(c *gin.Context) {
|
func (s *Service) GetChatRooms(c *gin.Context) {
|
||||||
|
|
||||||
if query := c.Query("query"); len(query) > 0 {
|
q := struct {
|
||||||
chatRoom := s.db.GetChatRoom(query)
|
Keyword string `form:"keyword"`
|
||||||
if chatRoom != nil {
|
Limit int `form:"limit"`
|
||||||
c.JSON(http.StatusOK, chatRoom)
|
Offset int `form:"offset"`
|
||||||
return
|
Format string `form:"format"`
|
||||||
}
|
}{}
|
||||||
|
|
||||||
|
if err := c.BindQuery(&q); err != nil {
|
||||||
|
errors.Err(c, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
list, err := s.db.ListChatRoom()
|
list, err := s.db.GetChatRooms(q.Keyword, q.Limit, q.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors.Err(c, err)
|
errors.Err(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
format := strings.ToLower(c.Query("format"))
|
format := strings.ToLower(q.Format)
|
||||||
switch format {
|
switch format {
|
||||||
case "json":
|
case "json":
|
||||||
// json
|
// json
|
||||||
@@ -190,32 +209,34 @@ func (s *Service) ListChatRoom(c *gin.Context) {
|
|||||||
c.Writer.Header().Set("Connection", "keep-alive")
|
c.Writer.Header().Set("Connection", "keep-alive")
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
|
|
||||||
c.Writer.WriteString("Name,Owner,UserCount\n")
|
c.Writer.WriteString("Name,Remark,NickName,Owner,UserCount\n")
|
||||||
for _, chatRoom := range list.Items {
|
for _, chatRoom := range list.Items {
|
||||||
c.Writer.WriteString(fmt.Sprintf("%s,%s,%d\n", chatRoom.Name, chatRoom.Owner, len(chatRoom.Users)))
|
c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
|
||||||
}
|
}
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) GetSession(c *gin.Context) {
|
func (s *Service) GetSessions(c *gin.Context) {
|
||||||
|
|
||||||
var err error
|
q := struct {
|
||||||
var limit int
|
Keyword string `form:"keyword"`
|
||||||
if _limit := c.Query("limit"); len(_limit) > 0 {
|
Limit int `form:"limit"`
|
||||||
limit, err = strconv.Atoi(_limit)
|
Offset int `form:"offset"`
|
||||||
if err != nil {
|
Format string `form:"format"`
|
||||||
errors.Err(c, errors.ErrInvalidArg("limit"))
|
}{}
|
||||||
return
|
|
||||||
}
|
if err := c.BindQuery(&q); err != nil {
|
||||||
|
errors.Err(c, err)
|
||||||
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
sessions, err := s.db.GetSession(limit)
|
sessions, err := s.db.GetSessions(q.Keyword, q.Limit, q.Offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
errors.Err(c, err)
|
errors.Err(c, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
format := strings.ToLower(c.Query("format"))
|
format := strings.ToLower(q.Format)
|
||||||
switch format {
|
switch format {
|
||||||
case "csv":
|
case "csv":
|
||||||
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
|
||||||
@@ -243,3 +264,126 @@ func (s *Service) GetSession(c *gin.Context) {
|
|||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetImage(c *gin.Context) {
|
||||||
|
s.GetMedia(c, "image")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetVideo(c *gin.Context) {
|
||||||
|
s.GetMedia(c, "video")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetFile(c *gin.Context) {
|
||||||
|
s.GetMedia(c, "file")
|
||||||
|
}
|
||||||
|
func (s *Service) GetVoice(c *gin.Context) {
|
||||||
|
s.GetMedia(c, "voice")
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetMedia(c *gin.Context, _type string) {
|
||||||
|
key := strings.TrimPrefix(c.Param("key"), "/")
|
||||||
|
if key == "" {
|
||||||
|
errors.Err(c, errors.InvalidArg(key))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
keys := util.Str2List(key, ",")
|
||||||
|
if len(keys) == 0 {
|
||||||
|
errors.Err(c, errors.InvalidArg(key))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var _err error
|
||||||
|
for _, k := range keys {
|
||||||
|
if len(k) != 32 {
|
||||||
|
absolutePath := filepath.Join(s.ctx.DataDir, k)
|
||||||
|
if _, err := os.Stat(absolutePath); os.IsNotExist(err) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
c.Redirect(http.StatusFound, "/data/"+k)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
media, err := s.db.GetMedia(_type, k)
|
||||||
|
if err != nil {
|
||||||
|
_err = err
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if c.Query("info") != "" {
|
||||||
|
c.JSON(http.StatusOK, media)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
switch media.Type {
|
||||||
|
case "voice":
|
||||||
|
s.HandleVoice(c, media.Data)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
c.Redirect(http.StatusFound, "/data/"+media.Path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if _err != nil {
|
||||||
|
errors.Err(c, _err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) GetMediaData(c *gin.Context) {
|
||||||
|
relativePath := filepath.Clean(c.Param("path"))
|
||||||
|
|
||||||
|
absolutePath := filepath.Join(s.ctx.DataDir, relativePath)
|
||||||
|
|
||||||
|
if _, err := os.Stat(absolutePath); os.IsNotExist(err) {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{
|
||||||
|
"error": "File not found",
|
||||||
|
})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ext := strings.ToLower(filepath.Ext(absolutePath))
|
||||||
|
switch {
|
||||||
|
case ext == ".dat":
|
||||||
|
s.HandleDatFile(c, absolutePath)
|
||||||
|
default:
|
||||||
|
// 直接返回文件
|
||||||
|
c.File(absolutePath)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HandleDatFile(c *gin.Context, path string) {
|
||||||
|
|
||||||
|
b, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
errors.Err(c, err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
out, ext, err := dat2img.Dat2Image(b)
|
||||||
|
if err != nil {
|
||||||
|
c.File(path)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
switch ext {
|
||||||
|
case "jpg":
|
||||||
|
c.Data(http.StatusOK, "image/jpeg", out)
|
||||||
|
case "png":
|
||||||
|
c.Data(http.StatusOK, "image/png", out)
|
||||||
|
case "gif":
|
||||||
|
c.Data(http.StatusOK, "image/gif", out)
|
||||||
|
case "bmp":
|
||||||
|
c.Data(http.StatusOK, "image/bmp", out)
|
||||||
|
default:
|
||||||
|
c.Data(http.StatusOK, "image/jpg", out)
|
||||||
|
// c.File(path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) HandleVoice(c *gin.Context, data []byte) {
|
||||||
|
out, err := silk.Silk2MP3(data)
|
||||||
|
if err != nil {
|
||||||
|
c.Data(http.StatusOK, "audio/silk", data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
c.Data(http.StatusOK, "audio/mp3", out)
|
||||||
|
}
|
||||||
|
|||||||
@@ -11,7 +11,11 @@ import (
|
|||||||
"github.com/sjzar/chatlog/internal/errors"
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
log "github.com/sirupsen/logrus"
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
DefalutHTTPAddr = "127.0.0.1:5030"
|
||||||
)
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
@@ -29,13 +33,14 @@ func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Servi
|
|||||||
|
|
||||||
// Handle error from SetTrustedProxies
|
// Handle error from SetTrustedProxies
|
||||||
if err := router.SetTrustedProxies(nil); err != nil {
|
if err := router.SetTrustedProxies(nil); err != nil {
|
||||||
log.Error("Failed to set trusted proxies:", err)
|
log.Err(err).Msg("Failed to set trusted proxies")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Middleware
|
// Middleware
|
||||||
router.Use(
|
router.Use(
|
||||||
gin.Recovery(),
|
errors.RecoveryMiddleware(),
|
||||||
gin.LoggerWithWriter(log.StandardLogger().Out),
|
errors.ErrorHandlerMiddleware(),
|
||||||
|
gin.LoggerWithWriter(log.Logger),
|
||||||
)
|
)
|
||||||
|
|
||||||
s := &Service{
|
s := &Service{
|
||||||
@@ -50,6 +55,11 @@ func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Servi
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) Start() error {
|
func (s *Service) Start() error {
|
||||||
|
|
||||||
|
if s.ctx.HTTPAddr == "" {
|
||||||
|
s.ctx.HTTPAddr = DefalutHTTPAddr
|
||||||
|
}
|
||||||
|
|
||||||
s.server = &http.Server{
|
s.server = &http.Server{
|
||||||
Addr: s.ctx.HTTPAddr,
|
Addr: s.ctx.HTTPAddr,
|
||||||
Handler: s.router,
|
Handler: s.router,
|
||||||
@@ -58,15 +68,30 @@ func (s *Service) Start() error {
|
|||||||
go func() {
|
go func() {
|
||||||
// Handle error from Run
|
// Handle error from Run
|
||||||
if err := s.server.ListenAndServe(); err != nil {
|
if err := s.server.ListenAndServe(); err != nil {
|
||||||
log.Error("Server Stopped: ", err)
|
log.Err(err).Msg("Failed to start HTTP server")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
log.Info("Server started on ", s.ctx.HTTPAddr)
|
log.Info().Msg("Starting HTTP server on " + s.ctx.HTTPAddr)
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *Service) ListenAndServe() error {
|
||||||
|
|
||||||
|
if s.ctx.HTTPAddr == "" {
|
||||||
|
s.ctx.HTTPAddr = DefalutHTTPAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
s.server = &http.Server{
|
||||||
|
Addr: s.ctx.HTTPAddr,
|
||||||
|
Handler: s.router,
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Info().Msg("Starting HTTP server on " + s.ctx.HTTPAddr)
|
||||||
|
return s.server.ListenAndServe()
|
||||||
|
}
|
||||||
|
|
||||||
func (s *Service) Stop() error {
|
func (s *Service) Stop() error {
|
||||||
|
|
||||||
if s.server == nil {
|
if s.server == nil {
|
||||||
@@ -74,14 +99,15 @@ func (s *Service) Stop() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 使用超时上下文优雅关闭
|
// 使用超时上下文优雅关闭
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
if err := s.server.Shutdown(ctx); err != nil {
|
if err := s.server.Shutdown(ctx); err != nil {
|
||||||
return errors.HTTP("HTTP server shutdown error", err)
|
log.Debug().Err(err).Msg("Failed to shutdown HTTP server")
|
||||||
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Info("HTTP server stopped")
|
log.Info().Msg("HTTP server stopped")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,156 +1,757 @@
|
|||||||
|
|
||||||
|
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
<title>Chatlog</title>
|
<title>Chatlog</title>
|
||||||
<style>
|
<style>
|
||||||
.random-paragraph {
|
:root {
|
||||||
display: none;
|
--primary-color: #3498db;
|
||||||
|
--primary-dark: #2980b9;
|
||||||
|
--success-color: #2ecc71;
|
||||||
|
--success-dark: #27ae60;
|
||||||
|
--error-color: #e74c3c;
|
||||||
|
--bg-light: #f5f5f5;
|
||||||
|
--bg-white: #ffffff;
|
||||||
|
--text-color: #333333;
|
||||||
|
--border-color: #dddddd;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto,
|
||||||
|
Oxygen, Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
color: var(--text-color);
|
||||||
|
max-width: 1200px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
background-color: #fafafa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.welcome-text {
|
||||||
|
text-align: center;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-section {
|
||||||
|
background-color: var(--bg-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 25px;
|
||||||
|
width: 100%;
|
||||||
|
max-width: 850px;
|
||||||
|
margin-bottom: 30px;
|
||||||
|
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.05);
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: #2c3e50;
|
||||||
|
margin-bottom: 15px;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
color: var(--primary-color);
|
||||||
|
margin-top: 20px;
|
||||||
|
border-bottom: 2px solid var(--primary-color);
|
||||||
|
padding-bottom: 8px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
margin-top: 20px;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-link {
|
||||||
|
color: var(--primary-color);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: bold;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.docs-link:hover {
|
||||||
|
text-decoration: underline;
|
||||||
|
color: var(--primary-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-tester {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 10px;
|
||||||
|
padding: 25px;
|
||||||
|
margin-top: 20px;
|
||||||
|
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 18px;
|
||||||
|
}
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 6px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #34495e;
|
||||||
|
}
|
||||||
|
|
||||||
|
input,
|
||||||
|
select,
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 10px 12px;
|
||||||
|
border: 1px solid #ddd;
|
||||||
|
border-radius: 6px;
|
||||||
|
box-sizing: border-box;
|
||||||
|
font-size: 14px;
|
||||||
|
transition: all 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
input:focus,
|
||||||
|
select:focus,
|
||||||
|
textarea:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
box-shadow: 0 0 0 2px rgba(52, 152, 219, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
input::placeholder,
|
||||||
|
textarea::placeholder {
|
||||||
|
color: #aaa;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background-color: var(--primary-color);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
padding: 12px 18px;
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
font-size: 16px;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.3s;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:hover {
|
||||||
|
background-color: var(--primary-dark);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
button:active {
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
|
||||||
|
.result-container {
|
||||||
|
margin-top: 20px;
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
padding: 15px;
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
max-height: 400px;
|
||||||
|
overflow-y: auto;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo,
|
||||||
|
monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
line-height: 1.5;
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.request-url {
|
||||||
|
background-color: #f0f0f0;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 10px;
|
||||||
|
font-family: "SFMono-Regular", Consolas, "Liberation Mono", Menlo,
|
||||||
|
monospace;
|
||||||
|
font-size: 14px;
|
||||||
|
word-break: break-all;
|
||||||
|
border: 1px dashed #ccc;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.url-text {
|
||||||
|
flex-grow: 1;
|
||||||
|
margin-right: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-url-button {
|
||||||
|
background-color: #9b59b6;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading {
|
||||||
|
text-align: center;
|
||||||
|
padding: 20px;
|
||||||
|
color: #666;
|
||||||
|
}
|
||||||
|
|
||||||
|
.loading::after {
|
||||||
|
content: "...";
|
||||||
|
animation: dots 1.5s steps(5, end) infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes dots {
|
||||||
|
0%,
|
||||||
|
20% {
|
||||||
|
content: ".";
|
||||||
}
|
}
|
||||||
|
40% {
|
||||||
|
content: "..";
|
||||||
|
}
|
||||||
|
60% {
|
||||||
|
content: "...";
|
||||||
|
}
|
||||||
|
80%,
|
||||||
|
100% {
|
||||||
|
content: "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-container {
|
||||||
|
display: flex;
|
||||||
|
margin-bottom: 20px;
|
||||||
|
border-bottom: 1px solid #e0e0e0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab {
|
||||||
|
padding: 12px 20px;
|
||||||
|
cursor: pointer;
|
||||||
|
margin-right: 5px;
|
||||||
|
border-radius: 6px 6px 0 0;
|
||||||
|
font-weight: 500;
|
||||||
|
transition: all 0.2s;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-bottom: none;
|
||||||
|
position: relative;
|
||||||
|
bottom: -1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab:hover {
|
||||||
|
background-color: #f0f8ff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab.active {
|
||||||
|
background-color: var(--bg-white);
|
||||||
|
border-color: #e0e0e0;
|
||||||
|
color: var(--primary-color);
|
||||||
|
border-bottom: 1px solid white;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content {
|
||||||
|
display: none;
|
||||||
|
padding: 20px 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-content.active {
|
||||||
|
display: block;
|
||||||
|
animation: fadeIn 0.3s;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.button-group {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-top: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button {
|
||||||
|
background-color: var(--success-color);
|
||||||
|
padding: 8px 15px;
|
||||||
|
font-size: 14px;
|
||||||
|
margin-left: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.copy-button:hover {
|
||||||
|
background-color: var(--success-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
.error-message {
|
||||||
|
color: var(--error-color);
|
||||||
|
font-weight: bold;
|
||||||
|
margin-top: 10px;
|
||||||
|
padding: 10px;
|
||||||
|
border-radius: 4px;
|
||||||
|
background-color: rgba(231, 76, 60, 0.1);
|
||||||
|
border-left: 4px solid var(--error-color);
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.api-description {
|
||||||
|
margin-bottom: 15px;
|
||||||
|
color: #555;
|
||||||
|
}
|
||||||
|
|
||||||
|
.badge {
|
||||||
|
display: inline-block;
|
||||||
|
padding: 3px 8px;
|
||||||
|
border-radius: 12px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 600;
|
||||||
|
margin-left: 8px;
|
||||||
|
background-color: rgba(52, 152, 219, 0.1);
|
||||||
|
color: var(--primary-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.optional-param {
|
||||||
|
font-size: 12px;
|
||||||
|
color: #777;
|
||||||
|
margin-left: 5px;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
|
||||||
|
.required-field {
|
||||||
|
color: var(--error-color);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="paragraphContainer">
|
<div class="container">
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
<div class="welcome-text">
|
||||||
('-. .-. ('-. .-') _
|
<h1>🎉 恭喜!Chatlog 服务已成功启动</h1>
|
||||||
( OO ) / ( OO ).-. ( OO) )
|
<p>
|
||||||
.-----. ,--. ,--. / . --. / / '._ ,--. .-'),-----. ,----.
|
Chatlog 是一个帮助你轻松使用自己聊天数据的工具,现在你可以通过 HTTP
|
||||||
' .--./ | | | | | \-. \ |'--...__) | |.-') ( OO' .-. ' ' .-./-')
|
API 访问你的聊天记录、联系人和群聊信息。
|
||||||
| |('-. | .| | .-'-' | | '--. .--' | | OO ) / | | | | | |_( O- )
|
</p>
|
||||||
/_) |OO ) | | \| |_.' | | | | |`-' | \_) | |\| | | | .--, \
|
</div>
|
||||||
|| |`-'| | .-. | | .-. | | | (| '---.' \ | | | |(| | '. (_/
|
|
||||||
(_' '--'\ | | | | | | | | | | | | `' '-' ' | '--' |
|
|
||||||
`-----' `--' `--' `--' `--' `--' `------' `-----' `------'
|
|
||||||
</pre>
|
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
|
||||||
_____ _ _ _
|
|
||||||
/ __ \| | | | | |
|
|
||||||
| / \/| |__ __ _ | |_ | | ___ __ _
|
|
||||||
| | | '_ \ / _` || __|| | / _ \ / _` |
|
|
||||||
| \__/\| | | || (_| || |_ | || (_) || (_| |
|
|
||||||
\____/|_| |_| \__,_| \__||_| \___/ \__, |
|
|
||||||
__/ |
|
|
||||||
|___/
|
|
||||||
</pre>
|
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
|
||||||
|
|
||||||
,-----. ,--. ,--. ,--.
|
<div class="api-section">
|
||||||
' .--./ | ,---. ,--,--. ,-' '-. | | ,---. ,---.
|
<h2>🔍 API 接口与调试</h2>
|
||||||
| | | .-. | ' ,-. | '-. .-' | | | .-. | | .-. |
|
|
||||||
' '--'\ | | | | \ '-' | | | | | ' '-' ' ' '-' '
|
|
||||||
`-----' `--' `--' `--`--' `--' `--' `---' .`- /
|
|
||||||
`---'
|
|
||||||
</pre>
|
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
|
||||||
____ _ _ _
|
|
||||||
/ ___| | |__ __ _ | |_ | | ___ __ _
|
|
||||||
| | | '_ \ / _` | | __| | | / _ \ / _` |
|
|
||||||
| |___ | | | | | (_| | | |_ | | | (_) | | (_| |
|
|
||||||
\____| |_| |_| \__,_| \__| |_| \___/ \__, |
|
|
||||||
|___/
|
|
||||||
</pre>
|
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
|
||||||
_____ _____ _____ _____ _____ _______ _____
|
|
||||||
/\ \ /\ \ /\ \ /\ \ /\ \ /::\ \ /\ \
|
|
||||||
/::\ \ /::\____\ /::\ \ /::\ \ /::\____\ /::::\ \ /::\ \
|
|
||||||
/::::\ \ /:::/ / /::::\ \ \:::\ \ /:::/ / /::::::\ \ /::::\ \
|
|
||||||
/::::::\ \ /:::/ / /::::::\ \ \:::\ \ /:::/ / /::::::::\ \ /::::::\ \
|
|
||||||
/:::/\:::\ \ /:::/ / /:::/\:::\ \ \:::\ \ /:::/ / /:::/~~\:::\ \ /:::/\:::\ \
|
|
||||||
/:::/ \:::\ \ /:::/____/ /:::/__\:::\ \ \:::\ \ /:::/ / /:::/ \:::\ \ /:::/ \:::\ \
|
|
||||||
/:::/ \:::\ \ /::::\ \ /::::\ \:::\ \ /::::\ \ /:::/ / /:::/ / \:::\ \ /:::/ \:::\ \
|
|
||||||
/:::/ / \:::\ \ /::::::\ \ _____ /::::::\ \:::\ \ /::::::\ \ /:::/ / /:::/____/ \:::\____\ /:::/ / \:::\ \
|
|
||||||
/:::/ / \:::\ \ /:::/\:::\ \ /\ \ /:::/\:::\ \:::\ \ /:::/\:::\ \ /:::/ / |:::| | |:::| | /:::/ / \:::\ ___\
|
|
||||||
/:::/____/ \:::\____\/:::/ \:::\ /::\____\/:::/ \:::\ \:::\____\ /:::/ \:::\____\/:::/____/ |:::|____| |:::| |/:::/____/ ___\:::| |
|
|
||||||
\:::\ \ \::/ /\::/ \:::\ /:::/ /\::/ \:::\ /:::/ / /:::/ \::/ /\:::\ \ \:::\ \ /:::/ / \:::\ \ /\ /:::|____|
|
|
||||||
\:::\ \ \/____/ \/____/ \:::\/:::/ / \/____/ \:::\/:::/ / /:::/ / \/____/ \:::\ \ \:::\ \ /:::/ / \:::\ /::\ \::/ /
|
|
||||||
\:::\ \ \::::::/ / \::::::/ / /:::/ / \:::\ \ \:::\ /:::/ / \:::\ \:::\ \/____/
|
|
||||||
\:::\ \ \::::/ / \::::/ / /:::/ / \:::\ \ \:::\__/:::/ / \:::\ \:::\____\
|
|
||||||
\:::\ \ /:::/ / /:::/ / \::/ / \:::\ \ \::::::::/ / \:::\ /:::/ /
|
|
||||||
\:::\ \ /:::/ / /:::/ / \/____/ \:::\ \ \::::::/ / \:::\/:::/ /
|
|
||||||
\:::\ \ /:::/ / /:::/ / \:::\ \ \::::/ / \::::::/ /
|
|
||||||
\:::\____\ /:::/ / /:::/ / \:::\____\ \::/____/ \::::/ /
|
|
||||||
\::/ / \::/ / \::/ / \::/ / ~~ \::/____/
|
|
||||||
\/____/ \/____/ \/____/ \/____/
|
|
||||||
|
|
||||||
</pre>
|
<div class="api-tester">
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
<div class="tab-container">
|
||||||
___ _ _ __ ____ __ _____ ___
|
<div class="tab active" data-tab="session">最近会话</div>
|
||||||
/ __)( )_( ) /__\ (_ _)( ) ( _ ) / __)
|
<div class="tab" data-tab="chatroom">群聊</div>
|
||||||
( (__ ) _ ( /(__)\ )( )(__ )(_)( ( (_-.
|
<div class="tab" data-tab="contact">联系人</div>
|
||||||
\___)(_) (_)(__)(__) (__) (____)(_____) \___/
|
<div class="tab" data-tab="chatlog">聊天记录</div>
|
||||||
</pre>
|
</div>
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
|
||||||
________ ___ ___ ________ _________ ___ ________ ________
|
<!-- 会话查询表单 -->
|
||||||
|\ ____\ |\ \|\ \ |\ __ \ |\___ ___\ |\ \ |\ __ \ |\ ____\
|
<div class="tab-content active" id="session-tab">
|
||||||
\ \ \___| \ \ \\\ \ \ \ \|\ \ \|___ \ \_| \ \ \ \ \ \|\ \ \ \ \___|
|
<div class="api-description">
|
||||||
\ \ \ \ \ __ \ \ \ __ \ \ \ \ \ \ \ \ \ \\\ \ \ \ \ ___
|
<p>
|
||||||
\ \ \____ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \ \____ \ \ \\\ \ \ \ \|\ \
|
查询最近会话列表。<span class="badge">GET /api/v1/session</span>
|
||||||
\ \_______\ \ \__\ \__\ \ \__\ \__\ \ \__\ \ \_______\ \ \_______\ \ \_______\
|
</p>
|
||||||
\|_______| \|__|\|__| \|__|\|__| \|__| \|_______| \|_______| \|_______|
|
</div>
|
||||||
</pre>
|
<div class="form-group">
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
<label for="session-format"
|
||||||
╔═╗┬ ┬┌─┐┌┬┐┬ ┌─┐┌─┐
|
>输出格式:<span class="optional-param">可选</span></label
|
||||||
║ ├─┤├─┤ │ │ │ ││ ┬
|
>
|
||||||
╚═╝┴ ┴┴ ┴ ┴ ┴─┘└─┘└─┘
|
<select id="session-format">
|
||||||
</pre>
|
<option value="">默认</option>
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
<option value="json">JSON</option>
|
||||||
▄▄· ▄ .▄ ▄▄▄· ▄▄▄▄▄▄▄▌ ▄▄ •
|
<option value="text">纯文本</option>
|
||||||
▐█ ▌▪██▪▐█▐█ ▀█ •██ ██• ▪ ▐█ ▀ ▪
|
</select>
|
||||||
██ ▄▄██▀▐█▄█▀▀█ ▐█.▪██▪ ▄█▀▄ ▄█ ▀█▄
|
</div>
|
||||||
▐███▌██▌▐▀▐█ ▪▐▌ ▐█▌·▐█▌▐▌▐█▌.▐▌▐█▄▪▐█
|
</div>
|
||||||
·▀▀▀ ▀▀▀ · ▀ ▀ ▀▀▀ .▀▀▀ ▀█▄▀▪·▀▀▀▀
|
|
||||||
</pre>
|
<!-- 群聊查询表单 -->
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
<div class="tab-content" id="chatroom-tab">
|
||||||
,. - ., .·¨'`; ,.·´¨;\ ,., ' , . ., ° ,. ' , ·. ,.-·~·., ‘ ,.-·^*ª'` ·,
|
<div class="api-description">
|
||||||
,·'´ ,. - , ';\ '; ;'\ '; ;::\ ;´ '· ., ;'´ , ., _';\' / ';\ / ·'´,.-·-., `,'‚ .·´ ,·'´:¯'`·, '\‘
|
<p>
|
||||||
,·´ .'´\:::::;' ;:'\ ' ; ;::'\ ,' ;::'; .´ .-, ';\ \:´¨¯:;' `;::'\:'\ ,' ,'::'\ / .'´\:::::::'\ '\ ° ,´ ,'\:::::::::\,.·\'
|
查询群聊列表,可选择性地按关键词搜索。<span class="badge"
|
||||||
/ ,'´::::'\;:-/ ,' ::; ' ; ;::_';,. ,.' ;:::';° / /:\:'; ;:'\' \::::; ,'::_'\;' ,' ;:::';' ,·' ,'::::\:;:-·-:'; ';\‚ / /:::\;·'´¯'`·;\:::\°
|
>GET /api/v1/chatroom</span
|
||||||
,' ;':::::;'´ '; /\::;' ' .' ,. -·~-·, ;:::'; ' ,' ,'::::'\'; ;::'; ,' ,'::;' ‘ '; ,':::;' ;. ';:::;´ ,' ,':'\‚ ; ;:::;' '\;:·´
|
>
|
||||||
; ;:::::; '\*'´\::\' ° '; ;'\::::::::; '/::::; ,.-·' '·~^*'´¨, ';::; ; ;:::; ° ; ,':::;' ' '; ;::; ,'´ .'´\::';‚ '; ;::/ ,·´¯'; °
|
</p>
|
||||||
'; ';::::'; '\::'\/.' ; ';:;\;::-··; ;::::; ':, ,·:²*´¨¯'`; ;::'; ; ;::;' ‘ ,' ,'::;' '; ':;: ,.·´,.·´::::\;'° '; '·;' ,.·´, ;'\
|
</div>
|
||||||
\ '·:;:'_ ,. -·'´.·´\‘ ':,.·´\;' ;' ,' :::/ ' ,' / \::::::::'; ;::'; ; ;::;'‚ ; ';_:,.-·´';\‘ \·, `*´,.·'´::::::;·´ \'·. `'´,.·:´'; ;::\'
|
<div class="form-group">
|
||||||
'\:` · .,. -·:´::::::\' \:::::\ \·.'::::; ,' ,'::::\·²*'´¨¯':,'\:; ',.'\::;'‚ ', _,.-·'´:\:\‘ \\:¯::\:::::::;:·´ '\::\¯::::::::'; ;::'; ‘
|
<label for="chatroom-keyword"
|
||||||
\:::::::\:::::::;:·'´' \;:·´ \:\::'; \`¨\:::/ \::\' \::\:;'‚ \¨:::::::::::\'; `\:::::\;::·'´ ° `·:\:::;:·´';.·´\::;'
|
>搜索群聊:<span class="optional-param">可选</span></label
|
||||||
`· :;::\;::-·´ `·\;' '\::\;' '\;' ' \;:' ‘ '\;::_;:-·'´‘ ¯ ¯ \::::\;'‚
|
>
|
||||||
' `¨' ° '¨ ‘ '\:·´'
|
<input
|
||||||
</pre>
|
type="text"
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
id="chatroom-keyword"
|
||||||
▄████▄ ██░ ██ ▄▄▄ ▄▄▄█████▓ ██▓ ▒█████ ▄████
|
placeholder="输入关键词搜索群聊"
|
||||||
▒██▀ ▀█ ▓██░ ██▒▒████▄ ▓ ██▒ ▓▒▓██▒ ▒██▒ ██▒ ██▒ ▀█▒
|
/>
|
||||||
▒▓█ ▄ ▒██▀▀██░▒██ ▀█▄ ▒ ▓██░ ▒░▒██░ ▒██░ ██▒▒██░▄▄▄░
|
</div>
|
||||||
▒▓▓▄ ▄██▒░▓█ ░██ ░██▄▄▄▄██ ░ ▓██▓ ░ ▒██░ ▒██ ██░░▓█ ██▓
|
<div class="form-group">
|
||||||
▒ ▓███▀ ░░▓█▒░██▓ ▓█ ▓██▒ ▒██▒ ░ ░██████▒░ ████▓▒░░▒▓███▀▒
|
<label for="chatroom-format"
|
||||||
░ ░▒ ▒ ░ ▒ ░░▒░▒ ▒▒ ▓▒█░ ▒ ░░ ░ ▒░▓ ░░ ▒░▒░▒░ ░▒ ▒
|
>输出格式:<span class="optional-param">可选</span></label
|
||||||
░ ▒ ▒ ░▒░ ░ ▒ ▒▒ ░ ░ ░ ░ ▒ ░ ░ ▒ ▒░ ░ ░
|
>
|
||||||
░ ░ ░░ ░ ░ ▒ ░ ░ ░ ░ ░ ░ ▒ ░ ░ ░
|
<select id="chatroom-format">
|
||||||
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░
|
<option value="">默认</option>
|
||||||
░
|
<option value="json">JSON</option>
|
||||||
</pre>
|
<option value="text">纯文本</option>
|
||||||
<pre style="float:left;white-space:pre-wrap;" class="random-paragraph">
|
</select>
|
||||||
▄█▄ ▄ █ ██ ▄▄▄▄▀ █ ████▄ ▄▀
|
</div>
|
||||||
█▀ ▀▄ █ █ █ █ ▀▀▀ █ █ █ █ ▄▀
|
</div>
|
||||||
█ ▀ ██▀▀█ █▄▄█ █ █ █ █ █ ▀▄
|
|
||||||
█▄ ▄▀ █ █ █ █ █ ███▄ ▀████ █ █
|
<!-- 联系人查询表单 -->
|
||||||
▀███▀ █ █ ▀ ▀ ███
|
<div class="tab-content" id="contact-tab">
|
||||||
▀ █
|
<div class="api-description">
|
||||||
▀
|
<p>
|
||||||
</pre>
|
查询联系人列表,可选择性地按关键词搜索。<span class="badge"
|
||||||
|
>GET /api/v1/contact</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="contact-keyword"
|
||||||
|
>搜索联系人:<span class="optional-param">可选</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="contact-keyword"
|
||||||
|
placeholder="输入关键词搜索联系人"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="contact-format"
|
||||||
|
>输出格式:<span class="optional-param">可选</span></label
|
||||||
|
>
|
||||||
|
<select id="contact-format">
|
||||||
|
<option value="">默认</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="text">纯文本</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 聊天记录查询表单 -->
|
||||||
|
<div class="tab-content" id="chatlog-tab">
|
||||||
|
<div class="api-description">
|
||||||
|
<p>
|
||||||
|
查询指定时间范围内与特定联系人或群聊的聊天记录。<span
|
||||||
|
class="badge"
|
||||||
|
>GET /api/v1/chatlog</span
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="time"
|
||||||
|
>时间范围:<span class="required-field">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="time"
|
||||||
|
placeholder="例如:2023-01-01 或 2023-01-01~2023-01-31"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="talker"
|
||||||
|
>聊天对象:<span class="required-field">*</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="talker"
|
||||||
|
placeholder="wxid、群ID、备注名或昵称"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="sender"
|
||||||
|
>发送者:<span class="optional-param">可选</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="sender"
|
||||||
|
placeholder="指定消息发送者"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="keyword"
|
||||||
|
>关键词:<span class="optional-param">可选</span></label
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="keyword"
|
||||||
|
placeholder="搜索消息内容中的关键词"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="limit"
|
||||||
|
>返回数量:<span class="optional-param">可选</span></label
|
||||||
|
>
|
||||||
|
<input type="number" id="limit" placeholder="默认不做限制" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="offset"
|
||||||
|
>偏移量:<span class="optional-param">可选</span></label
|
||||||
|
>
|
||||||
|
<input type="number" id="offset" placeholder="默认 0" />
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="format"
|
||||||
|
>输出格式:<span class="optional-param">可选</span></label
|
||||||
|
>
|
||||||
|
<select id="format">
|
||||||
|
<option value="">默认</option>
|
||||||
|
<option value="text">纯文本</option>
|
||||||
|
<option value="json">JSON</option>
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button id="test-api">执行查询</button>
|
||||||
|
|
||||||
|
<div id="result-wrapper" style="display: none; margin-top: 20px">
|
||||||
|
<div class="request-url" id="request-url-container">
|
||||||
|
<span class="url-text" id="request-url"></span>
|
||||||
|
<button class="copy-button copy-url-button" id="copy-url">
|
||||||
|
复制请求URL
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="result-container" id="api-result">
|
||||||
|
<p>查询结果将显示在这里...</p>
|
||||||
|
</div>
|
||||||
|
<div class="button-group">
|
||||||
|
<button class="copy-button" id="copy-result">复制结果</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="error-message" id="error-message"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-section">
|
||||||
|
<h2>🤖 MCP 集成</h2>
|
||||||
|
<p>
|
||||||
|
Chatlog 支持 MCP (Model Context Protocol) SSE 协议,可与支持 MCP 的 AI
|
||||||
|
助手无缝集成。
|
||||||
|
</p>
|
||||||
|
<p>SSE 端点:<strong>/sse</strong></p>
|
||||||
|
<p>
|
||||||
|
详细集成指南请参考
|
||||||
|
<a
|
||||||
|
href="https://github.com/sjzar/chatlog/blob/main/docs/mcp.md"
|
||||||
|
class="docs-link"
|
||||||
|
target="_blank"
|
||||||
|
>MCP 集成指南</a
|
||||||
|
>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="api-section">
|
||||||
|
<h2>📚 更多资源</h2>
|
||||||
|
<p>
|
||||||
|
查看
|
||||||
|
<a
|
||||||
|
href="https://github.com/sjzar/chatlog"
|
||||||
|
class="docs-link"
|
||||||
|
target="_blank"
|
||||||
|
>GitHub 项目</a
|
||||||
|
>
|
||||||
|
获取完整文档和使用指南。
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
如果你有任何问题或建议,欢迎通过
|
||||||
|
<a
|
||||||
|
href="https://github.com/sjzar/chatlog/discussions"
|
||||||
|
class="docs-link"
|
||||||
|
target="_blank"
|
||||||
|
>Discussions</a
|
||||||
|
>
|
||||||
|
进行交流。
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
window.onload = function() {
|
// 标签切换功能
|
||||||
showRandomParagraph();
|
document.querySelectorAll(".tab").forEach((tab) => {
|
||||||
};
|
tab.addEventListener("click", function () {
|
||||||
function showRandomParagraph() {
|
// 移除所有标签的活动状态
|
||||||
const paragraphs = document.getElementsByClassName("random-paragraph");
|
document
|
||||||
for (let i = 0; i < paragraphs.length; i++) {
|
.querySelectorAll(".tab")
|
||||||
paragraphs[i].style.display = "none";
|
.forEach((t) => t.classList.remove("active"));
|
||||||
|
// 设置当前标签为活动状态
|
||||||
|
this.classList.add("active");
|
||||||
|
|
||||||
|
// 隐藏所有内容区域
|
||||||
|
document.querySelectorAll(".tab-content").forEach((content) => {
|
||||||
|
content.classList.remove("active");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 显示当前标签对应的内容
|
||||||
|
const tabId = this.getAttribute("data-tab") + "-tab";
|
||||||
|
document.getElementById(tabId).classList.add("active");
|
||||||
|
|
||||||
|
// 清空结果区域
|
||||||
|
document.getElementById("result-wrapper").style.display = "none";
|
||||||
|
document.getElementById("api-result").innerHTML =
|
||||||
|
"<p>查询结果将显示在这里...</p>";
|
||||||
|
document.getElementById("request-url").textContent = "";
|
||||||
|
document.getElementById("error-message").style.display = "none";
|
||||||
|
document.getElementById("error-message").textContent = "";
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// API 测试功能
|
||||||
|
document
|
||||||
|
.getElementById("test-api")
|
||||||
|
.addEventListener("click", async function () {
|
||||||
|
const resultContainer = document.getElementById("api-result");
|
||||||
|
const requestUrlContainer = document.getElementById("request-url");
|
||||||
|
const errorMessage = document.getElementById("error-message");
|
||||||
|
const resultWrapper = document.getElementById("result-wrapper");
|
||||||
|
|
||||||
|
errorMessage.style.display = "none";
|
||||||
|
errorMessage.textContent = "";
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 获取当前活动的标签
|
||||||
|
const activeTab = document
|
||||||
|
.querySelector(".tab.active")
|
||||||
|
.getAttribute("data-tab");
|
||||||
|
let url = "/api/v1/";
|
||||||
|
let params = new URLSearchParams();
|
||||||
|
|
||||||
|
// 根据不同的标签构建不同的请求
|
||||||
|
switch (activeTab) {
|
||||||
|
case "chatlog":
|
||||||
|
url += "chatlog";
|
||||||
|
const time = document.getElementById("time").value;
|
||||||
|
const talker = document.getElementById("talker").value;
|
||||||
|
const sender = document.getElementById("sender").value;
|
||||||
|
const keyword = document.getElementById("keyword").value;
|
||||||
|
const limit = document.getElementById("limit").value;
|
||||||
|
const offset = document.getElementById("offset").value;
|
||||||
|
const format = document.getElementById("format").value;
|
||||||
|
|
||||||
|
// 验证必填项
|
||||||
|
if (!time || !talker) {
|
||||||
|
errorMessage.textContent =
|
||||||
|
"错误: 时间范围和聊天对象为必填项!";
|
||||||
|
errorMessage.style.display = "block";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (time) params.append("time", time);
|
||||||
|
if (talker) params.append("talker", talker);
|
||||||
|
if (sender) params.append("sender", sender);
|
||||||
|
if (keyword) params.append("keyword", keyword);
|
||||||
|
if (limit) params.append("limit", limit);
|
||||||
|
if (offset) params.append("offset", offset);
|
||||||
|
if (format) params.append("format", format);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "contact":
|
||||||
|
url += "contact";
|
||||||
|
const contactKeyword =
|
||||||
|
document.getElementById("contact-keyword").value;
|
||||||
|
const contactFormat =
|
||||||
|
document.getElementById("contact-format").value;
|
||||||
|
|
||||||
|
if (contactKeyword) params.append("keyword", contactKeyword);
|
||||||
|
if (contactFormat) params.append("format", contactFormat);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "chatroom":
|
||||||
|
url += "chatroom";
|
||||||
|
const chatroomKeyword =
|
||||||
|
document.getElementById("chatroom-keyword").value;
|
||||||
|
const chatroomFormat =
|
||||||
|
document.getElementById("chatroom-format").value;
|
||||||
|
|
||||||
|
if (chatroomKeyword) params.append("keyword", chatroomKeyword);
|
||||||
|
if (chatroomFormat) params.append("format", chatroomFormat);
|
||||||
|
break;
|
||||||
|
|
||||||
|
case "session":
|
||||||
|
url += "session";
|
||||||
|
const sessionFormat =
|
||||||
|
document.getElementById("session-format").value;
|
||||||
|
|
||||||
|
if (sessionFormat) params.append("format", sessionFormat);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
const randomIndex = Math.floor(Math.random() * paragraphs.length);
|
|
||||||
paragraphs[randomIndex].style.display = "block";
|
// 添加参数到URL
|
||||||
}
|
const apiUrl = params.toString()
|
||||||
|
? `${url}?${params.toString()}`
|
||||||
|
: url;
|
||||||
|
|
||||||
|
// 获取完整URL(包含域名部分)
|
||||||
|
const fullUrl = window.location.origin + apiUrl;
|
||||||
|
|
||||||
|
// 显示完整请求URL
|
||||||
|
requestUrlContainer.textContent = fullUrl;
|
||||||
|
resultWrapper.style.display = "block";
|
||||||
|
|
||||||
|
// 显示加载中
|
||||||
|
resultContainer.innerHTML = '<div class="loading">加载中</div>';
|
||||||
|
|
||||||
|
// 发送请求
|
||||||
|
const response = await fetch(apiUrl);
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
throw new Error(`HTTP error! Status: ${response.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取响应内容类型
|
||||||
|
const contentType = response.headers.get("content-type");
|
||||||
|
let result;
|
||||||
|
|
||||||
|
if (contentType && contentType.includes("application/json")) {
|
||||||
|
// 如果是JSON,格式化显示
|
||||||
|
result = await response.json();
|
||||||
|
resultContainer.innerHTML = JSON.stringify(result, null, 2);
|
||||||
|
} else {
|
||||||
|
// 其他格式直接显示文本
|
||||||
|
result = await response.text();
|
||||||
|
resultContainer.innerHTML = result;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
resultContainer.innerHTML = "";
|
||||||
|
errorMessage.textContent = `查询出错: ${error.message}`;
|
||||||
|
errorMessage.style.display = "block";
|
||||||
|
console.error("API查询出错:", error);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 复制结果功能
|
||||||
|
document
|
||||||
|
.getElementById("copy-result")
|
||||||
|
.addEventListener("click", function () {
|
||||||
|
const resultText = document.getElementById("api-result").innerText;
|
||||||
|
copyToClipboard(resultText, this, "已复制结果!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 复制URL功能
|
||||||
|
document
|
||||||
|
.getElementById("copy-url")
|
||||||
|
.addEventListener("click", function () {
|
||||||
|
// 获取完整URL(包含域名部分)
|
||||||
|
const urlText = document.getElementById("request-url").innerText;
|
||||||
|
copyToClipboard(urlText, this, "已复制URL!");
|
||||||
|
});
|
||||||
|
|
||||||
|
// 通用复制功能
|
||||||
|
function copyToClipboard(text, button, successMessage) {
|
||||||
|
navigator.clipboard
|
||||||
|
.writeText(text)
|
||||||
|
.then(() => {
|
||||||
|
const originalText = button.textContent;
|
||||||
|
button.textContent = successMessage;
|
||||||
|
setTimeout(() => {
|
||||||
|
button.textContent = originalText;
|
||||||
|
}, 2000);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
console.error("复制失败:", err);
|
||||||
|
});
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
@@ -1,16 +1,21 @@
|
|||||||
package chatlog
|
package chatlog
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/conf"
|
"github.com/sjzar/chatlog/internal/chatlog/conf"
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/database"
|
"github.com/sjzar/chatlog/internal/chatlog/database"
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/http"
|
"github.com/sjzar/chatlog/internal/chatlog/http"
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/mcp"
|
"github.com/sjzar/chatlog/internal/chatlog/mcp"
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/wechat"
|
"github.com/sjzar/chatlog/internal/chatlog/wechat"
|
||||||
|
iwechat "github.com/sjzar/chatlog/internal/wechat"
|
||||||
"github.com/sjzar/chatlog/pkg/util"
|
"github.com/sjzar/chatlog/pkg/util"
|
||||||
|
"github.com/sjzar/chatlog/pkg/util/dat2img"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Manager 管理聊天日志应用
|
// Manager 管理聊天日志应用
|
||||||
@@ -67,7 +72,7 @@ func (m *Manager) Run() error {
|
|||||||
if m.ctx.HTTPEnabled {
|
if m.ctx.HTTPEnabled {
|
||||||
// 启动HTTP服务
|
// 启动HTTP服务
|
||||||
if err := m.StartService(); err != nil {
|
if err := m.StartService(); err != nil {
|
||||||
return err
|
m.StopService()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// 启动终端UI
|
// 启动终端UI
|
||||||
@@ -76,6 +81,33 @@ func (m *Manager) Run() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) Switch(info *iwechat.Account, history string) error {
|
||||||
|
if m.ctx.AutoDecrypt {
|
||||||
|
if err := m.StopAutoDecrypt(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.ctx.HTTPEnabled {
|
||||||
|
if err := m.stopService(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if info != nil {
|
||||||
|
m.ctx.SwitchCurrent(info)
|
||||||
|
} else {
|
||||||
|
m.ctx.SwitchHistory(history)
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.ctx.HTTPEnabled {
|
||||||
|
// 启动HTTP服务
|
||||||
|
if err := m.StartService(); err != nil {
|
||||||
|
log.Info().Err(err).Msg("启动服务失败")
|
||||||
|
m.StopService()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) StartService() error {
|
func (m *Manager) StartService() error {
|
||||||
|
|
||||||
// 按依赖顺序启动服务
|
// 按依赖顺序启动服务
|
||||||
@@ -94,6 +126,11 @@ func (m *Manager) StartService() error {
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 如果是 4.0 版本,更新下 xorkey
|
||||||
|
if m.ctx.Version == 4 {
|
||||||
|
go dat2img.ScanAndSetXorKey(m.ctx.DataDir)
|
||||||
|
}
|
||||||
|
|
||||||
// 更新状态
|
// 更新状态
|
||||||
m.ctx.SetHTTPEnabled(true)
|
m.ctx.SetHTTPEnabled(true)
|
||||||
|
|
||||||
@@ -101,6 +138,17 @@ func (m *Manager) StartService() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) StopService() error {
|
func (m *Manager) StopService() error {
|
||||||
|
if err := m.stopService(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新状态
|
||||||
|
m.ctx.SetHTTPEnabled(false)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) stopService() error {
|
||||||
// 按依赖的反序停止服务
|
// 按依赖的反序停止服务
|
||||||
var errs []error
|
var errs []error
|
||||||
|
|
||||||
@@ -116,9 +164,6 @@ func (m *Manager) StopService() error {
|
|||||||
errs = append(errs, err)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// 更新状态
|
|
||||||
m.ctx.SetHTTPEnabled(false)
|
|
||||||
|
|
||||||
// 如果有错误,返回第一个错误
|
// 如果有错误,返回第一个错误
|
||||||
if len(errs) > 0 {
|
if len(errs) > 0 {
|
||||||
return errs[0]
|
return errs[0]
|
||||||
@@ -127,6 +172,21 @@ func (m *Manager) StopService() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) SetHTTPAddr(text string) error {
|
||||||
|
var addr string
|
||||||
|
if util.IsNumeric(text) {
|
||||||
|
addr = fmt.Sprintf("127.0.0.1:%s", text)
|
||||||
|
} else if strings.HasPrefix(text, "http://") {
|
||||||
|
addr = strings.TrimPrefix(text, "http://")
|
||||||
|
} else if strings.HasPrefix(text, "https://") {
|
||||||
|
addr = strings.TrimPrefix(text, "https://")
|
||||||
|
} else {
|
||||||
|
addr = text
|
||||||
|
}
|
||||||
|
m.ctx.SetHTTPAddr(addr)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) GetDataKey() error {
|
func (m *Manager) GetDataKey() error {
|
||||||
if m.ctx.Current == nil {
|
if m.ctx.Current == nil {
|
||||||
return fmt.Errorf("未选择任何账号")
|
return fmt.Errorf("未选择任何账号")
|
||||||
@@ -152,7 +212,7 @@ func (m *Manager) DecryptDBFiles() error {
|
|||||||
m.ctx.WorkDir = util.DefaultWorkDir(m.ctx.Account)
|
m.ctx.WorkDir = util.DefaultWorkDir(m.ctx.Account)
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := m.wechat.DecryptDBFiles(m.ctx.DataDir, m.ctx.WorkDir, m.ctx.DataKey, m.ctx.MajorVersion); err != nil {
|
if err := m.wechat.DecryptDBFiles(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
m.ctx.Refresh()
|
m.ctx.Refresh()
|
||||||
@@ -160,30 +220,72 @@ func (m *Manager) DecryptDBFiles() error {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) StartAutoDecrypt() error {
|
||||||
|
if m.ctx.DataKey == "" || m.ctx.DataDir == "" {
|
||||||
|
return fmt.Errorf("请先获取密钥")
|
||||||
|
}
|
||||||
|
if m.ctx.WorkDir == "" {
|
||||||
|
return fmt.Errorf("请先执行解密数据")
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.wechat.StartAutoDecrypt(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.ctx.SetAutoDecrypt(true)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) StopAutoDecrypt() error {
|
||||||
|
if err := m.wechat.StopAutoDecrypt(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
m.ctx.SetAutoDecrypt(false)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) RefreshSession() error {
|
||||||
|
if m.db.GetDB() == nil {
|
||||||
|
if err := m.db.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
resp, err := m.db.GetSessions("", 1, 0)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if len(resp.Items) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
m.ctx.LastSession = resp.Items[0].NTime
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (m *Manager) CommandKey(pid int) (string, error) {
|
func (m *Manager) CommandKey(pid int) (string, error) {
|
||||||
instances := m.wechat.GetWeChatInstances()
|
instances := m.wechat.GetWeChatInstances()
|
||||||
if len(instances) == 0 {
|
if len(instances) == 0 {
|
||||||
return "", fmt.Errorf("wechat process not found")
|
return "", fmt.Errorf("wechat process not found")
|
||||||
}
|
}
|
||||||
if len(instances) == 1 {
|
if len(instances) == 1 {
|
||||||
return instances[0].GetKey()
|
return instances[0].GetKey(context.Background())
|
||||||
}
|
}
|
||||||
if pid == 0 {
|
if pid == 0 {
|
||||||
str := "Select a process:\n"
|
str := "Select a process:\n"
|
||||||
for _, ins := range instances {
|
for _, ins := range instances {
|
||||||
str += fmt.Sprintf("PID: %d. %s[Version: %s Data Dir: %s ]\n", ins.PID, ins.AccountName, ins.Version.FileVersion, ins.DataDir)
|
str += fmt.Sprintf("PID: %d. %s[Version: %s Data Dir: %s ]\n", ins.PID, ins.Name, ins.FullVersion, ins.DataDir)
|
||||||
}
|
}
|
||||||
return str, nil
|
return str, nil
|
||||||
}
|
}
|
||||||
for _, ins := range instances {
|
for _, ins := range instances {
|
||||||
if ins.PID == uint32(pid) {
|
if ins.PID == uint32(pid) {
|
||||||
return ins.GetKey()
|
return ins.GetKey(context.Background())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return "", fmt.Errorf("wechat process not found")
|
return "", fmt.Errorf("wechat process not found")
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, version int) error {
|
func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, platform string, version int) error {
|
||||||
if dataDir == "" {
|
if dataDir == "" {
|
||||||
return fmt.Errorf("dataDir is required")
|
return fmt.Errorf("dataDir is required")
|
||||||
}
|
}
|
||||||
@@ -193,10 +295,55 @@ func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, ver
|
|||||||
if workDir == "" {
|
if workDir == "" {
|
||||||
workDir = util.DefaultWorkDir(filepath.Base(filepath.Dir(dataDir)))
|
workDir = util.DefaultWorkDir(filepath.Base(filepath.Dir(dataDir)))
|
||||||
}
|
}
|
||||||
|
m.ctx.DataDir = dataDir
|
||||||
if err := m.wechat.DecryptDBFiles(dataDir, workDir, key, version); err != nil {
|
m.ctx.WorkDir = workDir
|
||||||
|
m.ctx.DataKey = key
|
||||||
|
m.ctx.Platform = platform
|
||||||
|
m.ctx.Version = version
|
||||||
|
if err := m.wechat.DecryptDBFiles(); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (m *Manager) CommandHTTPServer(addr string, dataDir string, workDir string, platform string, version int) error {
|
||||||
|
|
||||||
|
if addr == "" {
|
||||||
|
addr = "127.0.0.1:5030"
|
||||||
|
}
|
||||||
|
|
||||||
|
if workDir == "" {
|
||||||
|
return fmt.Errorf("workDir is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if platform == "" {
|
||||||
|
return fmt.Errorf("platform is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
if version == 0 {
|
||||||
|
return fmt.Errorf("version is required")
|
||||||
|
}
|
||||||
|
|
||||||
|
m.ctx.HTTPAddr = addr
|
||||||
|
m.ctx.DataDir = dataDir
|
||||||
|
m.ctx.WorkDir = workDir
|
||||||
|
m.ctx.Platform = platform
|
||||||
|
m.ctx.Version = version
|
||||||
|
|
||||||
|
// 如果是 4.0 版本,更新下 xorkey
|
||||||
|
if m.ctx.Version == 4 && m.ctx.DataDir != "" {
|
||||||
|
go dat2img.ScanAndSetXorKey(m.ctx.DataDir)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按依赖顺序启动服务
|
||||||
|
if err := m.db.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := m.mcp.Start(); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
return m.http.ListenAndServe()
|
||||||
|
}
|
||||||
|
|||||||
@@ -21,11 +21,12 @@ var (
|
|||||||
InputSchema: mcp.ToolSchema{
|
InputSchema: mcp.ToolSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Properties: mcp.M{
|
Properties: mcp.M{
|
||||||
"query": mcp.M{
|
"keyword": mcp.M{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "联系人的搜索关键词,可以是姓名、备注名或ID。",
|
"description": "联系人的搜索关键词,可以是姓名、备注名或ID。",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Required: []string{"keyword"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -35,11 +36,12 @@ var (
|
|||||||
InputSchema: mcp.ToolSchema{
|
InputSchema: mcp.ToolSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Properties: mcp.M{
|
Properties: mcp.M{
|
||||||
"query": mcp.M{
|
"keyword": mcp.M{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "群聊的搜索关键词,可以是群名称、群ID或相关描述",
|
"description": "群聊的搜索关键词,可以是群名称、群ID或相关描述",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Required: []string{"keyword"},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -53,20 +55,133 @@ var (
|
|||||||
}
|
}
|
||||||
|
|
||||||
ToolChatLog = mcp.Tool{
|
ToolChatLog = mcp.Tool{
|
||||||
Name: "chatlog",
|
Name: "chatlog",
|
||||||
Description: "查询特定时间或时间段内与特定联系人或群组的聊天记录。当用户需要回顾过去的对话内容、查找特定信息或想了解与某人/某群的历史交流时使用此工具。",
|
Description: `检索历史聊天记录,可根据时间、对话方、发送者和关键词等条件进行精确查询。当用户需要查找特定信息或想了解与某人/某群的历史交流时使用此工具。
|
||||||
|
|
||||||
|
【强制多步查询流程!】
|
||||||
|
当查询特定话题或特定发送者发言时,必须严格按照以下流程使用,任何偏离都会导致错误的结果:
|
||||||
|
|
||||||
|
步骤1: 初步定位相关消息
|
||||||
|
- 使用keyword参数查找特定话题
|
||||||
|
- 使用sender参数查找特定发送者的消息
|
||||||
|
- 使用较宽时间范围初步查询
|
||||||
|
|
||||||
|
步骤2: 【必须执行】针对每个关键结果点分别获取上下文
|
||||||
|
- 必须对步骤1返回的每个时间点T1, T2, T3...分别执行独立查询(时间范围接近的消息可以合并为一个查询)
|
||||||
|
- 每次独立查询必须移除keyword参数
|
||||||
|
- 每次独立查询必须移除sender参数
|
||||||
|
- 每次独立查询使用"Tn前后15-30分钟"的窄范围
|
||||||
|
- 每次独立查询仅保留talker参数
|
||||||
|
|
||||||
|
步骤3: 【必须执行】综合分析所有上下文
|
||||||
|
- 必须等待所有步骤2的查询结果返回后再进行分析
|
||||||
|
- 必须综合考虑所有上下文信息后再回答用户
|
||||||
|
|
||||||
|
【严格执行规则!】
|
||||||
|
- 禁止仅凭步骤1的结果直接回答用户
|
||||||
|
- 禁止在步骤2使用过大的时间范围一次性查询所有上下文
|
||||||
|
- 禁止跳过步骤2或步骤3
|
||||||
|
- 必须对每个关键结果点分别执行独立的上下文查询
|
||||||
|
|
||||||
|
【执行示例】
|
||||||
|
正确流程示例:
|
||||||
|
1. 步骤1: chatlog(time="2023-04-01~2023-04-30", talker="工作群", keyword="项目进度")
|
||||||
|
返回结果: 4月5日、4月12日、4月20日有相关消息
|
||||||
|
2. 步骤2:
|
||||||
|
- 查询1: chatlog(time="2023-04-05/09:30~2023-04-05/10:30", talker="工作群") // 注意没有keyword
|
||||||
|
- 查询2: chatlog(time="2023-04-12/14:00~2023-04-12/15:00", talker="工作群") // 注意没有keyword
|
||||||
|
- 查询3: chatlog(time="2023-04-20/16:00~2023-04-20/17:00", talker="工作群") // 注意没有keyword
|
||||||
|
3. 步骤3: 综合分析所有上下文后回答用户
|
||||||
|
|
||||||
|
错误流程示例:
|
||||||
|
- 仅执行步骤1后直接回答
|
||||||
|
- 步骤2使用time="2023-04-01~2023-04-30"一次性查询
|
||||||
|
- 步骤2仍然保留keyword或sender参数
|
||||||
|
|
||||||
|
【自我检查】回答用户前必须自问:
|
||||||
|
- 我是否对每个关键时间点都执行了独立的上下文查询?
|
||||||
|
- 我是否在上下文查询中移除了keyword和sender参数?
|
||||||
|
- 我是否分析了所有上下文后再回答?
|
||||||
|
- 如果上述任一问题答案为"否",则必须纠正流程
|
||||||
|
|
||||||
|
返回格式:"昵称(ID) 时间\n消息内容\n昵称(ID) 时间\n消息内容"
|
||||||
|
当查询多个Talker时,返回格式为:"昵称(ID)\n[TalkerName(Talker)] 时间\n消息内容"
|
||||||
|
|
||||||
|
重要提示:
|
||||||
|
1. 当用户询问特定时间段内的聊天记录时,必须使用正确的时间格式,特别是包含小时和分钟的查询
|
||||||
|
2. 对于"今天下午4点到5点聊了啥"这类查询,正确的时间参数格式应为"2023-04-18/16:00~2023-04-18/17:00"
|
||||||
|
3. 当用户询问具体群聊中某人的聊天记录时,使用"sender"参数
|
||||||
|
4. 当用户询问包含特定关键词的聊天记录时,使用"keyword"参数`,
|
||||||
InputSchema: mcp.ToolSchema{
|
InputSchema: mcp.ToolSchema{
|
||||||
Type: "object",
|
Type: "object",
|
||||||
Properties: mcp.M{
|
Properties: mcp.M{
|
||||||
"time": mcp.M{
|
"time": mcp.M{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "查询的时间点或时间段。可以是具体时间,例如 YYYY-MM-DD,也可以是时间段,例如 YYYY-MM-DD~YYYY-MM-DD,时间段之间用\"~\"分隔。",
|
"description": `指定查询的时间点或时间范围,格式必须严格遵循以下规则:
|
||||||
|
|
||||||
|
【单一时间点格式】
|
||||||
|
- 精确到日:"2023-04-18"或"20230418"
|
||||||
|
- 精确到分钟(必须包含斜杠和冒号):"2023-04-18/14:30"或"20230418/14:30"(表示2023年4月18日14点30分)
|
||||||
|
|
||||||
|
【时间范围格式】(使用"~"分隔起止时间)
|
||||||
|
- 日期范围:"2023-04-01~2023-04-18"
|
||||||
|
- 同一天的时间段:"2023-04-18/14:30~2023-04-18/15:45"
|
||||||
|
* 表示2023年4月18日14点30分到15点45分之间
|
||||||
|
|
||||||
|
【重要提示】包含小时分钟的格式必须使用斜杠和冒号:"/"和":"
|
||||||
|
正确示例:"2023-04-18/16:30"(4月18日下午4点30分)
|
||||||
|
错误示例:"2023-04-18 16:30"、"2023-04-18T16:30"
|
||||||
|
|
||||||
|
【其他支持的格式】
|
||||||
|
- 年份:"2023"
|
||||||
|
- 月份:"2023-04"或"202304"`,
|
||||||
},
|
},
|
||||||
"talker": mcp.M{
|
"talker": mcp.M{
|
||||||
"type": "string",
|
"type": "string",
|
||||||
"description": "交谈对象,可以是联系人或群聊。支持使用ID、昵称、备注名等进行查询。",
|
"description": `指定对话方(联系人或群组)
|
||||||
|
- 可使用ID、昵称或备注名
|
||||||
|
- 多个对话方用","分隔,如:"张三,李四,工作群"
|
||||||
|
- 【重要】这是多步查询中唯一应保留的参数`,
|
||||||
|
},
|
||||||
|
"sender": mcp.M{
|
||||||
|
"type": "string",
|
||||||
|
"description": `指定群聊中的发送者
|
||||||
|
- 仅在查询群聊记录时有效
|
||||||
|
- 多个发送者用","分隔,如:"张三,李四"
|
||||||
|
- 可使用ID、昵称或备注名
|
||||||
|
【重要】查询特定发送者的消息时:
|
||||||
|
1. 第一步:使用sender参数初步定位多个相关消息时间点
|
||||||
|
2. 后续步骤:必须移除sender参数,分别查询每个时间点前后的完整对话
|
||||||
|
3. 错误示例:对所有找到的消息一次性查询大范围上下文
|
||||||
|
4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带sender)`,
|
||||||
|
},
|
||||||
|
"keyword": mcp.M{
|
||||||
|
"type": "string",
|
||||||
|
"description": `搜索内容中的关键词
|
||||||
|
- 支持正则表达式匹配
|
||||||
|
- 【重要】查询特定话题时:
|
||||||
|
1. 第一步:使用keyword参数初步定位多个相关消息时间点
|
||||||
|
2. 后续步骤:必须移除keyword参数,分别查询每个时间点前后的完整对话
|
||||||
|
3. 错误示例:对所有找到的关键词消息一次性查询大范围上下文
|
||||||
|
4. 正确示例:对每个时间点T分别执行查询"T前后15-30分钟"(不带keyword)`,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
Required: []string{"time", "talker"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
ToolCurrentTime = mcp.Tool{
|
||||||
|
Name: "current_time",
|
||||||
|
Description: `获取当前系统时间,返回RFC3339格式的时间字符串(包含用户本地时区信息)。
|
||||||
|
使用场景:
|
||||||
|
- 当用户询问"总结今日聊天记录"、"本周都聊了啥"等当前时间问题
|
||||||
|
- 当用户提及"昨天"、"上周"、"本月"等相对时间概念,需要确定基准时间点
|
||||||
|
- 需要执行依赖当前时间的计算(如"上个月5号我们有开会吗")
|
||||||
|
返回示例:2025-04-18T21:29:00+08:00
|
||||||
|
注意:此工具不需要任何输入参数,直接调用即可获取当前时间。`,
|
||||||
|
InputSchema: mcp.ToolSchema{
|
||||||
|
Type: "object",
|
||||||
|
Properties: mcp.M{},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"strings"
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/database"
|
"github.com/sjzar/chatlog/internal/chatlog/database"
|
||||||
@@ -83,6 +84,7 @@ func (s *Service) processMCP(session *mcp.Session, req *mcp.Request) {
|
|||||||
ToolChatRoom,
|
ToolChatRoom,
|
||||||
ToolRecentChat,
|
ToolRecentChat,
|
||||||
ToolChatLog,
|
ToolChatLog,
|
||||||
|
ToolCurrentTime,
|
||||||
}})
|
}})
|
||||||
case mcp.MethodToolsCall:
|
case mcp.MethodToolsCall:
|
||||||
err = s.toolsCall(session, req)
|
err = s.toolsCall(session, req)
|
||||||
@@ -130,57 +132,43 @@ func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
|
|||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
switch callReq.Name {
|
switch callReq.Name {
|
||||||
case "query_contact":
|
case "query_contact":
|
||||||
query := ""
|
keyword := ""
|
||||||
if v, ok := callReq.Arguments["query"]; ok {
|
if v, ok := callReq.Arguments["keyword"]; ok {
|
||||||
query = v.(string)
|
keyword = v.(string)
|
||||||
}
|
}
|
||||||
if len(query) == 0 {
|
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||||
list, err := s.db.ListContact()
|
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||||
if err != nil {
|
list, err := s.db.GetContacts(keyword, limit, offset)
|
||||||
return fmt.Errorf("无法获取联系人列表: %v", err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("无法获取联系人列表: %v", err)
|
||||||
buf.WriteString("UserName,Alias,Remark,NickName\n")
|
}
|
||||||
for _, contact := range list.Items {
|
buf.WriteString("UserName,Alias,Remark,NickName\n")
|
||||||
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
|
for _, contact := range list.Items {
|
||||||
}
|
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
|
||||||
} else {
|
|
||||||
contact := s.db.GetContact(query)
|
|
||||||
if contact == nil {
|
|
||||||
return fmt.Errorf("无法获取联系人: %s", query)
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(contact)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("无法序列化联系人: %v", err)
|
|
||||||
}
|
|
||||||
buf.Write(b)
|
|
||||||
}
|
}
|
||||||
case "query_chat_room":
|
case "query_chat_room":
|
||||||
query := ""
|
keyword := ""
|
||||||
if v, ok := callReq.Arguments["query"]; ok {
|
if v, ok := callReq.Arguments["keyword"]; ok {
|
||||||
query = v.(string)
|
keyword = v.(string)
|
||||||
}
|
}
|
||||||
if len(query) == 0 {
|
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||||
list, err := s.db.ListChatRoom()
|
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||||
if err != nil {
|
list, err := s.db.GetChatRooms(keyword, limit, offset)
|
||||||
return fmt.Errorf("无法获取群聊列表: %v", err)
|
if err != nil {
|
||||||
}
|
return fmt.Errorf("无法获取群聊列表: %v", err)
|
||||||
buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
|
}
|
||||||
for _, chatRoom := range list.Items {
|
buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
|
||||||
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
|
for _, chatRoom := range list.Items {
|
||||||
}
|
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
|
||||||
} else {
|
|
||||||
chatRoom := s.db.GetChatRoom(query)
|
|
||||||
if chatRoom == nil {
|
|
||||||
return fmt.Errorf("无法获取群聊: %s", query)
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(chatRoom)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("无法序列化群聊: %v", err)
|
|
||||||
}
|
|
||||||
buf.Write(b)
|
|
||||||
}
|
}
|
||||||
case "query_recent_chat":
|
case "query_recent_chat":
|
||||||
data, err := s.db.GetSession(0)
|
keyword := ""
|
||||||
|
if v, ok := callReq.Arguments["keyword"]; ok {
|
||||||
|
keyword = v.(string)
|
||||||
|
}
|
||||||
|
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||||
|
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||||
|
data, err := s.db.GetSessions(keyword, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("无法获取会话列表: %v", err)
|
return fmt.Errorf("无法获取会话列表: %v", err)
|
||||||
}
|
}
|
||||||
@@ -204,19 +192,29 @@ func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
|
|||||||
if v, ok := callReq.Arguments["talker"]; ok {
|
if v, ok := callReq.Arguments["talker"]; ok {
|
||||||
talker = v.(string)
|
talker = v.(string)
|
||||||
}
|
}
|
||||||
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
sender := ""
|
||||||
if limit == 0 {
|
if v, ok := callReq.Arguments["sender"]; ok {
|
||||||
limit = 100
|
sender = v.(string)
|
||||||
}
|
}
|
||||||
|
keyword := ""
|
||||||
|
if v, ok := callReq.Arguments["keyword"]; ok {
|
||||||
|
keyword = v.(string)
|
||||||
|
}
|
||||||
|
limit := util.MustAnyToInt(callReq.Arguments["limit"])
|
||||||
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
offset := util.MustAnyToInt(callReq.Arguments["offset"])
|
||||||
messages, err := s.db.GetMessages(start, end, talker, limit, offset)
|
messages, err := s.db.GetMessages(start, end, talker, sender, keyword, limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("无法获取聊天记录: %v", err)
|
return fmt.Errorf("无法获取聊天记录: %v", err)
|
||||||
}
|
}
|
||||||
|
if len(messages) == 0 {
|
||||||
|
buf.WriteString("未找到符合查询条件的聊天记录")
|
||||||
|
}
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
buf.WriteString(m.PlainText(len(talker) == 0))
|
buf.WriteString(m.PlainText(strings.Contains(talker, ","), util.PerfectTimeFormat(start, end), ""))
|
||||||
buf.WriteString("\n")
|
buf.WriteString("\n")
|
||||||
}
|
}
|
||||||
|
case "current_time":
|
||||||
|
buf.WriteString(time.Now().Local().Format(time.RFC3339))
|
||||||
default:
|
default:
|
||||||
return fmt.Errorf("未支持的工具: %s", callReq.Name)
|
return fmt.Errorf("未支持的工具: %s", callReq.Name)
|
||||||
}
|
}
|
||||||
@@ -245,49 +243,25 @@ func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
|
|||||||
buf := &bytes.Buffer{}
|
buf := &bytes.Buffer{}
|
||||||
switch u.Scheme {
|
switch u.Scheme {
|
||||||
case "contact":
|
case "contact":
|
||||||
if len(u.Host) == 0 {
|
list, err := s.db.GetContacts(u.Host, 0, 0)
|
||||||
list, err := s.db.ListContact()
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("无法获取联系人列表: %v", err)
|
||||||
return fmt.Errorf("无法获取联系人列表: %v", err)
|
}
|
||||||
}
|
buf.WriteString("UserName,Alias,Remark,NickName\n")
|
||||||
buf.WriteString("UserName,Alias,Remark,NickName\n")
|
for _, contact := range list.Items {
|
||||||
for _, contact := range list.Items {
|
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
|
||||||
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
contact := s.db.GetContact(u.Host)
|
|
||||||
if contact == nil {
|
|
||||||
return fmt.Errorf("无法获取联系人: %s", u.Host)
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(contact)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("无法序列化联系人: %v", err)
|
|
||||||
}
|
|
||||||
buf.Write(b)
|
|
||||||
}
|
}
|
||||||
case "chatroom":
|
case "chatroom":
|
||||||
if len(u.Host) == 0 {
|
list, err := s.db.GetChatRooms(u.Host, 0, 0)
|
||||||
list, err := s.db.ListChatRoom()
|
if err != nil {
|
||||||
if err != nil {
|
return fmt.Errorf("无法获取群聊列表: %v", err)
|
||||||
return fmt.Errorf("无法获取群聊列表: %v", err)
|
}
|
||||||
}
|
buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
|
||||||
buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
|
for _, chatRoom := range list.Items {
|
||||||
for _, chatRoom := range list.Items {
|
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
|
||||||
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
chatRoom := s.db.GetChatRoom(u.Host)
|
|
||||||
if chatRoom == nil {
|
|
||||||
return fmt.Errorf("无法获取群聊: %s", u.Host)
|
|
||||||
}
|
|
||||||
b, err := json.Marshal(chatRoom)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("无法序列化群聊: %v", err)
|
|
||||||
}
|
|
||||||
buf.Write(b)
|
|
||||||
}
|
}
|
||||||
case "session":
|
case "session":
|
||||||
data, err := s.db.GetSession(0)
|
data, err := s.db.GetSessions("", 0, 0)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("无法获取会话列表: %v", err)
|
return fmt.Errorf("无法获取会话列表: %v", err)
|
||||||
}
|
}
|
||||||
@@ -301,16 +275,16 @@ func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
|
|||||||
return fmt.Errorf("无法解析时间范围")
|
return fmt.Errorf("无法解析时间范围")
|
||||||
}
|
}
|
||||||
limit := util.MustAnyToInt(u.Query().Get("limit"))
|
limit := util.MustAnyToInt(u.Query().Get("limit"))
|
||||||
if limit == 0 {
|
|
||||||
limit = 100
|
|
||||||
}
|
|
||||||
offset := util.MustAnyToInt(u.Query().Get("offset"))
|
offset := util.MustAnyToInt(u.Query().Get("offset"))
|
||||||
messages, err := s.db.GetMessages(start, end, u.Host, limit, offset)
|
messages, err := s.db.GetMessages(start, end, u.Host, "", "", limit, offset)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("无法获取聊天记录: %v", err)
|
return fmt.Errorf("无法获取聊天记录: %v", err)
|
||||||
}
|
}
|
||||||
|
if len(messages) == 0 {
|
||||||
|
buf.WriteString("未找到符合查询条件的聊天记录")
|
||||||
|
}
|
||||||
for _, m := range messages {
|
for _, m := range messages {
|
||||||
buf.WriteString(m.PlainText(len(u.Host) == 0))
|
buf.WriteString(m.PlainText(strings.Contains(u.Host, ","), util.PerfectTimeFormat(start, end), ""))
|
||||||
buf.WriteString("\n")
|
buf.WriteString("\n")
|
||||||
}
|
}
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -1,39 +1,58 @@
|
|||||||
package wechat
|
package wechat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"context"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
"github.com/sjzar/chatlog/internal/chatlog/ctx"
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
"github.com/sjzar/chatlog/internal/wechat"
|
"github.com/sjzar/chatlog/internal/wechat"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||||
|
"github.com/sjzar/chatlog/pkg/filemonitor"
|
||||||
"github.com/sjzar/chatlog/pkg/util"
|
"github.com/sjzar/chatlog/pkg/util"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
DebounceTime = 1 * time.Second
|
||||||
|
MaxWaitTime = 10 * time.Second
|
||||||
|
)
|
||||||
|
|
||||||
type Service struct {
|
type Service struct {
|
||||||
ctx *ctx.Context
|
ctx *ctx.Context
|
||||||
|
lastEvents map[string]time.Time
|
||||||
|
pendingActions map[string]bool
|
||||||
|
mutex sync.Mutex
|
||||||
|
fm *filemonitor.FileMonitor
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewService(ctx *ctx.Context) *Service {
|
func NewService(ctx *ctx.Context) *Service {
|
||||||
return &Service{
|
return &Service{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
|
lastEvents: make(map[string]time.Time),
|
||||||
|
pendingActions: make(map[string]bool),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetWeChatInstances returns all running WeChat instances
|
// GetWeChatInstances returns all running WeChat instances
|
||||||
func (s *Service) GetWeChatInstances() []*wechat.Info {
|
func (s *Service) GetWeChatInstances() []*wechat.Account {
|
||||||
wechat.Load()
|
wechat.Load()
|
||||||
return wechat.Items
|
return wechat.GetAccounts()
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetDataKey extracts the encryption key from a WeChat process
|
// GetDataKey extracts the encryption key from a WeChat process
|
||||||
func (s *Service) GetDataKey(info *wechat.Info) (string, error) {
|
func (s *Service) GetDataKey(info *wechat.Account) (string, error) {
|
||||||
if info == nil {
|
if info == nil {
|
||||||
return "", fmt.Errorf("no WeChat instance selected")
|
return "", fmt.Errorf("no WeChat instance selected")
|
||||||
}
|
}
|
||||||
|
|
||||||
key, err := info.GetKey()
|
key, err := info.GetKey(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -41,71 +60,128 @@ func (s *Service) GetDataKey(info *wechat.Info) (string, error) {
|
|||||||
return key, nil
|
return key, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// FindDBFiles finds all .db files in the specified directory
|
func (s *Service) StartAutoDecrypt() error {
|
||||||
func (s *Service) FindDBFiles(rootDir string, recursive bool) ([]string, error) {
|
dbGroup, err := filemonitor.NewFileGroup("wechat", s.ctx.DataDir, `.*\.db$`, []string{"fts"})
|
||||||
// Check if directory exists
|
|
||||||
info, err := os.Stat(rootDir)
|
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, fmt.Errorf("cannot access directory %s: %w", rootDir, err)
|
return err
|
||||||
}
|
}
|
||||||
|
dbGroup.AddCallback(s.DecryptFileCallback)
|
||||||
|
|
||||||
if !info.IsDir() {
|
s.fm = filemonitor.NewFileMonitor()
|
||||||
return nil, fmt.Errorf("%s is not a directory", rootDir)
|
s.fm.AddGroup(dbGroup)
|
||||||
|
if err := s.fm.Start(); err != nil {
|
||||||
|
log.Debug().Err(err).Msg("failed to start file monitor")
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
var dbFiles []string
|
func (s *Service) StopAutoDecrypt() error {
|
||||||
|
if s.fm != nil {
|
||||||
// Define walk function
|
if err := s.fm.Stop(); err != nil {
|
||||||
walkFunc := func(path string, info os.FileInfo, err error) error {
|
return err
|
||||||
if err != nil {
|
|
||||||
// If a file or directory can't be accessed, log the error but continue
|
|
||||||
fmt.Printf("Warning: Cannot access %s: %v\n", path, err)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If it's a directory and not the root directory, and we're not recursively searching, skip it
|
|
||||||
if info.IsDir() && path != rootDir && !recursive {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file extension is .db
|
|
||||||
if !info.IsDir() && strings.ToLower(filepath.Ext(path)) == ".db" {
|
|
||||||
dbFiles = append(dbFiles, path)
|
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
s.fm = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DecryptFileCallback(event fsnotify.Event) error {
|
||||||
|
if event.Op.Has(fsnotify.Chmod) || !event.Op.Has(fsnotify.Write) {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Start traversal
|
s.mutex.Lock()
|
||||||
err = filepath.Walk(rootDir, walkFunc)
|
s.lastEvents[event.Name] = time.Now()
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("error traversing directory: %w", err)
|
if !s.pendingActions[event.Name] {
|
||||||
|
s.pendingActions[event.Name] = true
|
||||||
|
s.mutex.Unlock()
|
||||||
|
go s.waitAndProcess(event.Name)
|
||||||
|
} else {
|
||||||
|
s.mutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
if len(dbFiles) == 0 {
|
return nil
|
||||||
return nil, fmt.Errorf("no .db files found")
|
|
||||||
}
|
|
||||||
|
|
||||||
return dbFiles, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *Service) DecryptDBFiles(dataDir string, workDir string, key string, version int) error {
|
func (s *Service) waitAndProcess(dbFile string) {
|
||||||
|
start := time.Now()
|
||||||
|
for {
|
||||||
|
time.Sleep(DebounceTime)
|
||||||
|
|
||||||
dbfiles, err := s.FindDBFiles(dataDir, true)
|
s.mutex.Lock()
|
||||||
|
lastEventTime := s.lastEvents[dbFile]
|
||||||
|
elapsed := time.Since(lastEventTime)
|
||||||
|
totalElapsed := time.Since(start)
|
||||||
|
|
||||||
|
if elapsed >= DebounceTime || totalElapsed >= MaxWaitTime {
|
||||||
|
s.pendingActions[dbFile] = false
|
||||||
|
s.mutex.Unlock()
|
||||||
|
|
||||||
|
log.Debug().Msgf("Processing file: %s", dbFile)
|
||||||
|
s.DecryptDBFile(dbFile)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.mutex.Unlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DecryptDBFile(dbFile string) error {
|
||||||
|
|
||||||
|
decryptor, err := decrypt.NewDecryptor(s.ctx.Platform, s.ctx.Version)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, dbfile := range dbfiles {
|
output := filepath.Join(s.ctx.WorkDir, dbFile[len(s.ctx.DataDir):])
|
||||||
output := filepath.Join(workDir, dbfile[len(dataDir):])
|
if err := util.PrepareDir(filepath.Dir(output)); err != nil {
|
||||||
if err := util.PrepareDir(filepath.Dir(output)); err != nil {
|
return err
|
||||||
return err
|
}
|
||||||
|
|
||||||
|
outputTemp := output + ".tmp"
|
||||||
|
outputFile, err := os.Create(outputTemp)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("failed to create output file: %v", err)
|
||||||
|
}
|
||||||
|
defer func() {
|
||||||
|
outputFile.Close()
|
||||||
|
if err := os.Rename(outputTemp, output); err != nil {
|
||||||
|
log.Debug().Err(err).Msgf("failed to rename %s to %s", outputTemp, output)
|
||||||
}
|
}
|
||||||
if err := wechat.DecryptDBFileToFile(dbfile, output, key, version); err != nil {
|
}()
|
||||||
if err == wechat.ErrAlreadyDecrypted {
|
|
||||||
continue
|
if err := decryptor.Decrypt(context.Background(), dbFile, s.ctx.DataKey, outputFile); err != nil {
|
||||||
|
if err == errors.ErrAlreadyDecrypted {
|
||||||
|
if data, err := os.ReadFile(dbFile); err == nil {
|
||||||
|
outputFile.Write(data)
|
||||||
}
|
}
|
||||||
return err
|
return nil
|
||||||
|
}
|
||||||
|
log.Err(err).Msgf("failed to decrypt %s", dbFile)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
log.Debug().Msgf("Decrypted %s to %s", dbFile, output)
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *Service) DecryptDBFiles() error {
|
||||||
|
dbGroup, err := filemonitor.NewFileGroup("wechat", s.ctx.DataDir, `.*\.db$`, []string{"fts"})
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbFiles, err := dbGroup.List()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, dbFile := range dbFiles {
|
||||||
|
if err := s.DecryptDBFile(dbFile); err != nil {
|
||||||
|
log.Debug().Msgf("DecryptDBFile %s failed: %v", dbFile, err)
|
||||||
|
continue
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,85 +1,120 @@
|
|||||||
package errors
|
package errors
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"runtime"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 定义错误类型常量
|
type Error struct {
|
||||||
const (
|
Message string `json:"message"` // 错误消息
|
||||||
ErrTypeDatabase = "database"
|
Cause error `json:"-"` // 原始错误
|
||||||
ErrTypeWeChat = "wechat"
|
Code int `json:"-"` // HTTP Code
|
||||||
ErrTypeHTTP = "http"
|
Stack []string `json:"-"` // 错误堆栈
|
||||||
ErrTypeConfig = "config"
|
|
||||||
ErrTypeInvalidArg = "invalid_argument"
|
|
||||||
)
|
|
||||||
|
|
||||||
// AppError 表示应用程序错误
|
|
||||||
type AppError struct {
|
|
||||||
Type string `json:"type"` // 错误类型
|
|
||||||
Message string `json:"message"` // 错误消息
|
|
||||||
Cause error `json:"-"` // 原始错误
|
|
||||||
Code int `json:"-"` // HTTP Code
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *AppError) Error() string {
|
func (e *Error) Error() string {
|
||||||
if e.Cause != nil {
|
if e.Cause != nil {
|
||||||
return fmt.Sprintf("%s: %s: %v", e.Type, e.Message, e.Cause)
|
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("%s: %s", e.Type, e.Message)
|
return fmt.Sprintf("%s", e.Message)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (e *AppError) String() string {
|
func (e *Error) String() string {
|
||||||
return e.Error()
|
return e.Error()
|
||||||
}
|
}
|
||||||
|
|
||||||
// New 创建新的应用错误
|
func (e *Error) Unwrap() error {
|
||||||
func New(errType, message string, cause error, code int) *AppError {
|
return e.Cause
|
||||||
return &AppError{
|
}
|
||||||
Type: errType,
|
|
||||||
|
func (e *Error) WithStack() *Error {
|
||||||
|
const depth = 32
|
||||||
|
var pcs [depth]uintptr
|
||||||
|
n := runtime.Callers(2, pcs[:])
|
||||||
|
frames := runtime.CallersFrames(pcs[:n])
|
||||||
|
|
||||||
|
stack := make([]string, 0, n)
|
||||||
|
for {
|
||||||
|
frame, more := frames.Next()
|
||||||
|
if !strings.Contains(frame.File, "runtime/") {
|
||||||
|
stack = append(stack, fmt.Sprintf("%s:%d %s", frame.File, frame.Line, frame.Function))
|
||||||
|
}
|
||||||
|
if !more {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
e.Stack = stack
|
||||||
|
return e
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(cause error, code int, message string) *Error {
|
||||||
|
return &Error{
|
||||||
Message: message,
|
Message: message,
|
||||||
Cause: cause,
|
Cause: cause,
|
||||||
Code: code,
|
Code: code,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ErrInvalidArg 无效参数错误
|
func Newf(cause error, code int, format string, args ...interface{}) *Error {
|
||||||
func ErrInvalidArg(param string) *AppError {
|
return &Error{
|
||||||
return New(ErrTypeInvalidArg, fmt.Sprintf("invalid arg: %s", param), nil, http.StatusBadRequest)
|
Message: fmt.Sprintf(format, args...),
|
||||||
|
Cause: cause,
|
||||||
|
Code: code,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Database 创建数据库错误
|
func Wrap(err error, message string, code int) *Error {
|
||||||
func Database(message string, cause error) *AppError {
|
if err == nil {
|
||||||
return New(ErrTypeDatabase, message, cause, http.StatusInternalServerError)
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if appErr, ok := err.(*Error); ok {
|
||||||
|
return &Error{
|
||||||
|
Message: message,
|
||||||
|
Cause: appErr.Cause,
|
||||||
|
Code: appErr.Code,
|
||||||
|
Stack: appErr.Stack,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return New(err, code, message)
|
||||||
}
|
}
|
||||||
|
|
||||||
// WeChat 创建微信相关错误
|
func GetCode(err error) int {
|
||||||
func WeChat(message string, cause error) *AppError {
|
if err == nil {
|
||||||
return New(ErrTypeWeChat, message, cause, http.StatusInternalServerError)
|
return http.StatusOK
|
||||||
|
}
|
||||||
|
|
||||||
|
var appErr *Error
|
||||||
|
if errors.As(err, &appErr) {
|
||||||
|
return appErr.Code
|
||||||
|
}
|
||||||
|
|
||||||
|
return http.StatusInternalServerError
|
||||||
}
|
}
|
||||||
|
|
||||||
// HTTP 创建HTTP服务错误
|
func RootCause(err error) error {
|
||||||
func HTTP(message string, cause error) *AppError {
|
for err != nil {
|
||||||
return New(ErrTypeHTTP, message, cause, http.StatusInternalServerError)
|
unwrapped := errors.Unwrap(err)
|
||||||
|
if unwrapped == nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
err = unwrapped
|
||||||
|
}
|
||||||
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Config 创建配置错误
|
|
||||||
func Config(message string, cause error) *AppError {
|
|
||||||
return New(ErrTypeConfig, message, cause, http.StatusInternalServerError)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Err 在HTTP响应中返回错误
|
|
||||||
func Err(c *gin.Context, err error) {
|
func Err(c *gin.Context, err error) {
|
||||||
if appErr, ok := err.(*AppError); ok {
|
if appErr, ok := err.(*Error); ok {
|
||||||
c.JSON(appErr.Code, appErr)
|
c.JSON(appErr.Code, appErr.Error())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// 未知错误
|
c.JSON(http.StatusInternalServerError, err.Error())
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{
|
|
||||||
"type": "unknown",
|
|
||||||
"message": err.Error(),
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|||||||
11
internal/errors/http_errors.go
Normal file
11
internal/errors/http_errors.go
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func InvalidArg(arg string) error {
|
||||||
|
return Newf(nil, http.StatusBadRequest, "invalid argument: %s", arg)
|
||||||
|
}
|
||||||
|
|
||||||
|
func HTTPShutDown(cause error) error {
|
||||||
|
return Newf(cause, http.StatusInternalServerError, "http server shut down")
|
||||||
|
}
|
||||||
64
internal/errors/middleware.go
Normal file
64
internal/errors/middleware.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"runtime/debug"
|
||||||
|
|
||||||
|
"github.com/gin-gonic/gin"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ErrorHandlerMiddleware 是一个 Gin 中间件,用于统一处理请求过程中的错误
|
||||||
|
// 它会为每个请求生成一个唯一的请求 ID,并在错误发生时将其添加到错误响应中
|
||||||
|
func ErrorHandlerMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
// 生成请求 ID
|
||||||
|
requestID := uuid.New().String()
|
||||||
|
c.Set("RequestID", requestID)
|
||||||
|
c.Header("X-Request-ID", requestID)
|
||||||
|
|
||||||
|
// 处理请求
|
||||||
|
c.Next()
|
||||||
|
|
||||||
|
// 检查是否有错误
|
||||||
|
if len(c.Errors) > 0 {
|
||||||
|
// 获取第一个错误
|
||||||
|
err := c.Errors[0].Err
|
||||||
|
|
||||||
|
// 使用 Err 函数处理错误响应
|
||||||
|
Err(c, err)
|
||||||
|
|
||||||
|
// 已经处理过错误,不需要继续
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecoveryMiddleware 是一个 Gin 中间件,用于从 panic 恢复并返回 500 错误
|
||||||
|
func RecoveryMiddleware() gin.HandlerFunc {
|
||||||
|
return func(c *gin.Context) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
|
||||||
|
// 创建内部服务器错误
|
||||||
|
var err *Error
|
||||||
|
switch v := r.(type) {
|
||||||
|
case error:
|
||||||
|
err = New(v, http.StatusInternalServerError, "panic recovered")
|
||||||
|
default:
|
||||||
|
err = Newf(nil, http.StatusInternalServerError, "panic recovered: %v", r)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 记录错误日志
|
||||||
|
log.Err(err).Msgf("PANIC RECOVERED\n%s", string(debug.Stack()))
|
||||||
|
|
||||||
|
// 返回 500 错误
|
||||||
|
c.JSON(http.StatusInternalServerError, err)
|
||||||
|
c.Abort()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
c.Next()
|
||||||
|
}
|
||||||
|
}
|
||||||
23
internal/errors/os_errors.go
Normal file
23
internal/errors/os_errors.go
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
func OpenFileFailed(path string, cause error) *Error {
|
||||||
|
return Newf(cause, http.StatusInternalServerError, "failed to open file: %s", path).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func StatFileFailed(path string, cause error) *Error {
|
||||||
|
return Newf(cause, http.StatusInternalServerError, "failed to stat file: %s", path).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadFileFailed(path string, cause error) *Error {
|
||||||
|
return Newf(cause, http.StatusInternalServerError, "failed to read file: %s", path).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func IncompleteRead(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "incomplete header read during decryption").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func WriteOutputFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "failed to write output").WithStack()
|
||||||
|
}
|
||||||
65
internal/errors/wechat_errors.go
Normal file
65
internal/errors/wechat_errors.go
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import "net/http"
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrAlreadyDecrypted = New(nil, http.StatusBadRequest, "database file is already decrypted")
|
||||||
|
ErrDecryptHashVerificationFailed = New(nil, http.StatusBadRequest, "hash verification failed during decryption")
|
||||||
|
ErrDecryptIncorrectKey = New(nil, http.StatusBadRequest, "incorrect decryption key")
|
||||||
|
ErrDecryptOperationCanceled = New(nil, http.StatusBadRequest, "decryption operation was canceled")
|
||||||
|
ErrNoMemoryRegionsFound = New(nil, http.StatusBadRequest, "no memory regions found")
|
||||||
|
ErrReadMemoryTimeout = New(nil, http.StatusInternalServerError, "read memory timeout")
|
||||||
|
ErrWeChatOffline = New(nil, http.StatusBadRequest, "WeChat is offline")
|
||||||
|
ErrSIPEnabled = New(nil, http.StatusBadRequest, "SIP is enabled")
|
||||||
|
ErrValidatorNotSet = New(nil, http.StatusBadRequest, "validator not set")
|
||||||
|
ErrNoValidKey = New(nil, http.StatusBadRequest, "no valid key found")
|
||||||
|
ErrWeChatDLLNotFound = New(nil, http.StatusBadRequest, "WeChatWin.dll module not found")
|
||||||
|
)
|
||||||
|
|
||||||
|
func PlatformUnsupported(platform string, version int) *Error {
|
||||||
|
return Newf(nil, http.StatusBadRequest, "unsupported platform: %s v%d", platform, version).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptCreateCipherFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "failed to create cipher").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecodeKeyFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusBadRequest, "failed to decode hex key").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func CreatePipeFileFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "failed to create pipe file").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenPipeFileFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "failed to open pipe file").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadPipeFileFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "failed to read from pipe file").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RunCmdFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "failed to run command").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ReadMemoryFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "failed to read memory").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenProcessFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "failed to open process").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func WeChatAccountNotFound(name string) *Error {
|
||||||
|
return Newf(nil, http.StatusBadRequest, "WeChat account not found: %s", name).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func WeChatAccountNotOnline(name string) *Error {
|
||||||
|
return Newf(nil, http.StatusBadRequest, "WeChat account is not online: %s", name).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func RefreshProcessStatusFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "failed to refresh process status").WithStack()
|
||||||
|
}
|
||||||
66
internal/errors/wechatdb_errors.go
Normal file
66
internal/errors/wechatdb_errors.go
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package errors
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
ErrTalkerEmpty = New(nil, http.StatusBadRequest, "talker empty").WithStack()
|
||||||
|
ErrKeyEmpty = New(nil, http.StatusBadRequest, "key empty").WithStack()
|
||||||
|
ErrMediaNotFound = New(nil, http.StatusNotFound, "media not found").WithStack()
|
||||||
|
ErrKeyLengthMust32 = New(nil, http.StatusBadRequest, "key length must be 32 bytes").WithStack()
|
||||||
|
)
|
||||||
|
|
||||||
|
// 数据库初始化相关错误
|
||||||
|
func DBFileNotFound(path, pattern string, cause error) *Error {
|
||||||
|
return Newf(cause, http.StatusNotFound, "db file not found %s: %s", path, pattern).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DBConnectFailed(path string, cause error) *Error {
|
||||||
|
return Newf(cause, http.StatusInternalServerError, "db connect failed: %s", path).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DBInitFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "db init failed").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TalkerNotFound(talker string) *Error {
|
||||||
|
return Newf(nil, http.StatusNotFound, "talker not found: %s", talker).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func DBCloseFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "db close failed").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func QueryFailed(query string, cause error) *Error {
|
||||||
|
return Newf(cause, http.StatusInternalServerError, "query failed: %s", query).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ScanRowFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "scan row failed").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func TimeRangeNotFound(start, end time.Time) *Error {
|
||||||
|
return Newf(nil, http.StatusNotFound, "time range not found: %s - %s", start, end).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func MediaTypeUnsupported(_type string) *Error {
|
||||||
|
return Newf(nil, http.StatusBadRequest, "unsupported media type: %s", _type).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ChatRoomNotFound(key string) *Error {
|
||||||
|
return Newf(nil, http.StatusNotFound, "chat room not found: %s", key).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func ContactNotFound(key string) *Error {
|
||||||
|
return Newf(nil, http.StatusNotFound, "contact not found: %s", key).WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func InitCacheFailed(cause error) *Error {
|
||||||
|
return New(cause, http.StatusInternalServerError, "init cache failed").WithStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
func FileGroupNotFound(name string) *Error {
|
||||||
|
return Newf(nil, http.StatusNotFound, "file group not found: %s", name).WithStack()
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
log "github.com/sirupsen/logrus"
|
"github.com/rs/zerolog/log"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -85,7 +85,7 @@ func (m *MCP) HandleMessages(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Printf("收到消息: %v\n", req)
|
log.Debug().Msgf("session: %s, request: %s", sessionID, req)
|
||||||
select {
|
select {
|
||||||
case m.ProcessChan <- ProcessCtx{Session: session, Request: &req}:
|
case m.ProcessChan <- ProcessCtx{Session: session, Request: &req}:
|
||||||
default:
|
default:
|
||||||
|
|||||||
@@ -92,8 +92,9 @@ type Tool struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ToolSchema struct {
|
type ToolSchema struct {
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
Properties M `json:"properties"`
|
Properties M `json:"properties"`
|
||||||
|
Required []string `json:"required,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// {
|
// {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
package model
|
package model
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/sjzar/chatlog/pkg/model/wxproto"
|
"github.com/sjzar/chatlog/internal/model/wxproto"
|
||||||
|
|
||||||
"google.golang.org/protobuf/proto"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
@@ -84,33 +84,6 @@ func (c *ChatRoomV3) Wrap() *ChatRoom {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// CREATE TABLE chat_room(
|
|
||||||
// id INTEGER PRIMARY KEY,
|
|
||||||
// username TEXT,
|
|
||||||
// owner TEXT,
|
|
||||||
// ext_buffer BLOB
|
|
||||||
// )
|
|
||||||
type ChatRoomV4 struct {
|
|
||||||
ID int `json:"id"`
|
|
||||||
UserName string `json:"username"`
|
|
||||||
Owner string `json:"owner"`
|
|
||||||
ExtBuffer []byte `json:"ext_buffer"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *ChatRoomV4) Wrap() *ChatRoom {
|
|
||||||
|
|
||||||
var users []ChatRoomUser
|
|
||||||
if len(c.ExtBuffer) != 0 {
|
|
||||||
users = ParseRoomData(c.ExtBuffer)
|
|
||||||
}
|
|
||||||
|
|
||||||
return &ChatRoom{
|
|
||||||
Name: c.UserName,
|
|
||||||
Owner: c.Owner,
|
|
||||||
Users: users,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func ParseRoomData(b []byte) (users []ChatRoomUser) {
|
func ParseRoomData(b []byte) (users []ChatRoomUser) {
|
||||||
var pbMsg wxproto.RoomData
|
var pbMsg wxproto.RoomData
|
||||||
if err := proto.Unmarshal(b, &pbMsg); err != nil {
|
if err := proto.Unmarshal(b, &pbMsg); err != nil {
|
||||||
97
internal/model/chatroom_darwinv3.go
Normal file
97
internal/model/chatroom_darwinv3.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "strings"
|
||||||
|
|
||||||
|
// CREATE TABLE GroupContact(
|
||||||
|
// m_nsUsrName TEXT PRIMARY KEY ASC,
|
||||||
|
// m_uiConType INTEGER,
|
||||||
|
// nickname TEXT,
|
||||||
|
// m_nsFullPY TEXT,
|
||||||
|
// m_nsShortPY TEXT,
|
||||||
|
// m_nsRemark TEXT,
|
||||||
|
// m_nsRemarkPYFull TEXT,
|
||||||
|
// m_nsRemarkPYShort TEXT,
|
||||||
|
// m_uiCertificationFlag INTEGER,
|
||||||
|
// m_uiSex INTEGER,
|
||||||
|
// m_uiType INTEGER,
|
||||||
|
// m_nsImgStatus TEXT,
|
||||||
|
// m_uiImgKey INTEGER,
|
||||||
|
// m_nsHeadImgUrl TEXT,
|
||||||
|
// m_nsHeadHDImgUrl TEXT,
|
||||||
|
// m_nsHeadHDMd5 TEXT,
|
||||||
|
// m_nsChatRoomMemList TEXT,
|
||||||
|
// m_nsChatRoomAdminList TEXT,
|
||||||
|
// m_uiChatRoomStatus INTEGER,
|
||||||
|
// m_nsChatRoomDesc TEXT,
|
||||||
|
// m_nsDraft TEXT,
|
||||||
|
// m_nsBrandIconUrl TEXT,
|
||||||
|
// m_nsGoogleContactName TEXT,
|
||||||
|
// m_nsAliasName TEXT,
|
||||||
|
// m_nsEncodeUserName TEXT,
|
||||||
|
// m_uiChatRoomVersion INTEGER,
|
||||||
|
// m_uiChatRoomMaxCount INTEGER,
|
||||||
|
// m_uiChatRoomType INTEGER,
|
||||||
|
// m_patSuffix TEXT,
|
||||||
|
// richChatRoomDesc TEXT,
|
||||||
|
// _packed_WCContactData BLOB,
|
||||||
|
// openIMInfo BLOB
|
||||||
|
// )
|
||||||
|
type ChatRoomDarwinV3 struct {
|
||||||
|
M_nsUsrName string `json:"m_nsUsrName"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
M_nsRemark string `json:"m_nsRemark"`
|
||||||
|
M_nsChatRoomMemList string `json:"m_nsChatRoomMemList"`
|
||||||
|
M_nsChatRoomAdminList string `json:"m_nsChatRoomAdminList"`
|
||||||
|
|
||||||
|
// M_uiConType int `json:"m_uiConType"`
|
||||||
|
// M_nsFullPY string `json:"m_nsFullPY"`
|
||||||
|
// M_nsShortPY string `json:"m_nsShortPY"`
|
||||||
|
// M_nsRemarkPYFull string `json:"m_nsRemarkPYFull"`
|
||||||
|
// M_nsRemarkPYShort string `json:"m_nsRemarkPYShort"`
|
||||||
|
// M_uiCertificationFlag int `json:"m_uiCertificationFlag"`
|
||||||
|
// M_uiSex int `json:"m_uiSex"`
|
||||||
|
// M_uiType int `json:"m_uiType"`
|
||||||
|
// M_nsImgStatus string `json:"m_nsImgStatus"`
|
||||||
|
// M_uiImgKey int `json:"m_uiImgKey"`
|
||||||
|
// M_nsHeadImgUrl string `json:"m_nsHeadImgUrl"`
|
||||||
|
// M_nsHeadHDImgUrl string `json:"m_nsHeadHDImgUrl"`
|
||||||
|
// M_nsHeadHDMd5 string `json:"m_nsHeadHDMd5"`
|
||||||
|
// M_uiChatRoomStatus int `json:"m_uiChatRoomStatus"`
|
||||||
|
// M_nsChatRoomDesc string `json:"m_nsChatRoomDesc"`
|
||||||
|
// M_nsDraft string `json:"m_nsDraft"`
|
||||||
|
// M_nsBrandIconUrl string `json:"m_nsBrandIconUrl"`
|
||||||
|
// M_nsGoogleContactName string `json:"m_nsGoogleContactName"`
|
||||||
|
// M_nsAliasName string `json:"m_nsAliasName"`
|
||||||
|
// M_nsEncodeUserName string `json:"m_nsEncodeUserName"`
|
||||||
|
// M_uiChatRoomVersion int `json:"m_uiChatRoomVersion"`
|
||||||
|
// M_uiChatRoomMaxCount int `json:"m_uiChatRoomMaxCount"`
|
||||||
|
// M_uiChatRoomType int `json:"m_uiChatRoomType"`
|
||||||
|
// M_patSuffix string `json:"m_patSuffix"`
|
||||||
|
// RichChatRoomDesc string `json:"richChatRoomDesc"`
|
||||||
|
// Packed_WCContactData []byte `json:"_packed_WCContactData"`
|
||||||
|
// OpenIMInfo []byte `json:"openIMInfo"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ChatRoomDarwinV3) Wrap(user2DisplayName map[string]string) *ChatRoom {
|
||||||
|
|
||||||
|
split := strings.Split(c.M_nsChatRoomMemList, ";")
|
||||||
|
users := make([]ChatRoomUser, 0, len(split))
|
||||||
|
_user2DisplayName := make(map[string]string)
|
||||||
|
for _, v := range split {
|
||||||
|
users = append(users, ChatRoomUser{
|
||||||
|
UserName: v,
|
||||||
|
})
|
||||||
|
if name, ok := user2DisplayName[v]; ok {
|
||||||
|
_user2DisplayName[v] = name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ChatRoom{
|
||||||
|
Name: c.M_nsUsrName,
|
||||||
|
Owner: c.M_nsChatRoomAdminList,
|
||||||
|
Remark: c.M_nsRemark,
|
||||||
|
NickName: c.Nickname,
|
||||||
|
Users: users,
|
||||||
|
User2DisplayName: _user2DisplayName,
|
||||||
|
}
|
||||||
|
}
|
||||||
36
internal/model/chatroom_v4.go
Normal file
36
internal/model/chatroom_v4.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// CREATE TABLE chat_room(
|
||||||
|
// id INTEGER PRIMARY KEY,
|
||||||
|
// username TEXT,
|
||||||
|
// owner TEXT,
|
||||||
|
// ext_buffer BLOB
|
||||||
|
// )
|
||||||
|
type ChatRoomV4 struct {
|
||||||
|
ID int `json:"id"`
|
||||||
|
UserName string `json:"username"`
|
||||||
|
Owner string `json:"owner"`
|
||||||
|
ExtBuffer []byte `json:"ext_buffer"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ChatRoomV4) Wrap() *ChatRoom {
|
||||||
|
|
||||||
|
var users []ChatRoomUser
|
||||||
|
if len(c.ExtBuffer) != 0 {
|
||||||
|
users = ParseRoomData(c.ExtBuffer)
|
||||||
|
}
|
||||||
|
|
||||||
|
user2DisplayName := make(map[string]string, len(users))
|
||||||
|
for _, user := range users {
|
||||||
|
if user.DisplayName != "" {
|
||||||
|
user2DisplayName[user.UserName] = user.DisplayName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ChatRoom{
|
||||||
|
Name: c.UserName,
|
||||||
|
Owner: c.Owner,
|
||||||
|
Users: users,
|
||||||
|
User2DisplayName: user2DisplayName,
|
||||||
|
}
|
||||||
|
}
|
||||||
70
internal/model/contact.go
Normal file
70
internal/model/contact.go
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type Contact struct {
|
||||||
|
UserName string `json:"userName"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
NickName string `json:"nickName"`
|
||||||
|
IsFriend bool `json:"isFriend"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// CREATE TABLE Contact(
|
||||||
|
// UserName TEXT PRIMARY KEY ,
|
||||||
|
// Alias TEXT,
|
||||||
|
// EncryptUserName TEXT,
|
||||||
|
// DelFlag INTEGER DEFAULT 0,
|
||||||
|
// Type INTEGER DEFAULT 0,
|
||||||
|
// VerifyFlag INTEGER DEFAULT 0,
|
||||||
|
// Reserved1 INTEGER DEFAULT 0,
|
||||||
|
// Reserved2 INTEGER DEFAULT 0,
|
||||||
|
// Reserved3 TEXT,
|
||||||
|
// Reserved4 TEXT,
|
||||||
|
// Remark TEXT,
|
||||||
|
// NickName TEXT,
|
||||||
|
// LabelIDList TEXT,
|
||||||
|
// DomainList TEXT,
|
||||||
|
// ChatRoomType int,
|
||||||
|
// PYInitial TEXT,
|
||||||
|
// QuanPin TEXT,
|
||||||
|
// RemarkPYInitial TEXT,
|
||||||
|
// RemarkQuanPin TEXT,
|
||||||
|
// BigHeadImgUrl TEXT,
|
||||||
|
// SmallHeadImgUrl TEXT,
|
||||||
|
// HeadImgMd5 TEXT,
|
||||||
|
// ChatRoomNotify INTEGER DEFAULT 0,
|
||||||
|
// Reserved5 INTEGER DEFAULT 0,
|
||||||
|
// Reserved6 TEXT,
|
||||||
|
// Reserved7 TEXT,
|
||||||
|
// ExtraBuf BLOB,
|
||||||
|
// Reserved8 INTEGER DEFAULT 0,
|
||||||
|
// Reserved9 INTEGER DEFAULT 0,
|
||||||
|
// Reserved10 TEXT,
|
||||||
|
// Reserved11 TEXT
|
||||||
|
// )
|
||||||
|
type ContactV3 struct {
|
||||||
|
UserName string `json:"UserName"`
|
||||||
|
Alias string `json:"Alias"`
|
||||||
|
Remark string `json:"Remark"`
|
||||||
|
NickName string `json:"NickName"`
|
||||||
|
Reserved1 int `json:"Reserved1"` // 1 自己好友或自己加入的群聊; 0 群聊成员(非好友)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ContactV3) Wrap() *Contact {
|
||||||
|
return &Contact{
|
||||||
|
UserName: c.UserName,
|
||||||
|
Alias: c.Alias,
|
||||||
|
Remark: c.Remark,
|
||||||
|
NickName: c.NickName,
|
||||||
|
IsFriend: c.Reserved1 == 1,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *Contact) DisplayName() string {
|
||||||
|
switch {
|
||||||
|
case c.Remark != "":
|
||||||
|
return c.Remark
|
||||||
|
case c.NickName != "":
|
||||||
|
return c.NickName
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
53
internal/model/contact_darwinv3.go
Normal file
53
internal/model/contact_darwinv3.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// CREATE TABLE WCContact(
|
||||||
|
// m_nsUsrName TEXT PRIMARY KEY ASC,
|
||||||
|
// m_uiConType INTEGER,
|
||||||
|
// nickname TEXT,
|
||||||
|
// m_nsFullPY TEXT,
|
||||||
|
// m_nsShortPY TEXT,
|
||||||
|
// m_nsRemark TEXT,
|
||||||
|
// m_nsRemarkPYFull TEXT,
|
||||||
|
// m_nsRemarkPYShort TEXT,
|
||||||
|
// m_uiCertificationFlag INTEGER,
|
||||||
|
// m_uiSex INTEGER,
|
||||||
|
// m_uiType INTEGER,
|
||||||
|
// m_nsImgStatus TEXT,
|
||||||
|
// m_uiImgKey INTEGER,
|
||||||
|
// m_nsHeadImgUrl TEXT,
|
||||||
|
// m_nsHeadHDImgUrl TEXT,
|
||||||
|
// m_nsHeadHDMd5 TEXT,
|
||||||
|
// m_nsChatRoomMemList TEXT,
|
||||||
|
// m_nsChatRoomAdminList TEXT,
|
||||||
|
// m_uiChatRoomStatus INTEGER,
|
||||||
|
// m_nsChatRoomDesc TEXT,
|
||||||
|
// m_nsDraft TEXT,
|
||||||
|
// m_nsBrandIconUrl TEXT,
|
||||||
|
// m_nsGoogleContactName TEXT,
|
||||||
|
// m_nsAliasName TEXT,
|
||||||
|
// m_nsEncodeUserName TEXT,
|
||||||
|
// m_uiChatRoomVersion INTEGER,
|
||||||
|
// m_uiChatRoomMaxCount INTEGER,
|
||||||
|
// m_uiChatRoomType INTEGER,
|
||||||
|
// m_patSuffix TEXT,
|
||||||
|
// richChatRoomDesc TEXT,
|
||||||
|
// _packed_WCContactData BLOB,
|
||||||
|
// openIMInfo BLOB
|
||||||
|
// )
|
||||||
|
type ContactDarwinV3 struct {
|
||||||
|
M_nsUsrName string `json:"m_nsUsrName"`
|
||||||
|
Nickname string `json:"nickname"`
|
||||||
|
M_nsRemark string `json:"m_nsRemark"`
|
||||||
|
M_uiSex int `json:"m_uiSex"`
|
||||||
|
M_nsAliasName string `json:"m_nsAliasName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ContactDarwinV3) Wrap() *Contact {
|
||||||
|
return &Contact{
|
||||||
|
UserName: c.M_nsUsrName,
|
||||||
|
Alias: c.M_nsAliasName,
|
||||||
|
Remark: c.M_nsRemark,
|
||||||
|
NickName: c.Nickname,
|
||||||
|
IsFriend: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
43
internal/model/contact_v4.go
Normal file
43
internal/model/contact_v4.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
// CREATE TABLE contact(
|
||||||
|
// id INTEGER PRIMARY KEY,
|
||||||
|
// username TEXT,
|
||||||
|
// local_type INTEGER,
|
||||||
|
// alias TEXT,
|
||||||
|
// encrypt_username TEXT,
|
||||||
|
// flag INTEGER,
|
||||||
|
// delete_flag INTEGER,
|
||||||
|
// verify_flag INTEGER,
|
||||||
|
// remark TEXT,
|
||||||
|
// remark_quan_pin TEXT,
|
||||||
|
// remark_pin_yin_initial TEXT,
|
||||||
|
// nick_name TEXT,
|
||||||
|
// pin_yin_initial TEXT,
|
||||||
|
// quan_pin TEXT,
|
||||||
|
// big_head_url TEXT,
|
||||||
|
// small_head_url TEXT,
|
||||||
|
// head_img_md5 TEXT,
|
||||||
|
// chat_room_notify INTEGER,
|
||||||
|
// is_in_chat_room INTEGER,
|
||||||
|
// description TEXT,
|
||||||
|
// extra_buffer BLOB,
|
||||||
|
// chat_room_type INTEGER
|
||||||
|
// )
|
||||||
|
type ContactV4 struct {
|
||||||
|
UserName string `json:"username"`
|
||||||
|
Alias string `json:"alias"`
|
||||||
|
Remark string `json:"remark"`
|
||||||
|
NickName string `json:"nick_name"`
|
||||||
|
LocalType int `json:"local_type"` // 2 群聊; 3 群聊成员(非好友); 5,6 企业微信;
|
||||||
|
}
|
||||||
|
|
||||||
|
func (c *ContactV4) Wrap() *Contact {
|
||||||
|
return &Contact{
|
||||||
|
UserName: c.UserName,
|
||||||
|
Alias: c.Alias,
|
||||||
|
Remark: c.Remark,
|
||||||
|
NickName: c.NickName,
|
||||||
|
IsFriend: c.LocalType != 3,
|
||||||
|
}
|
||||||
|
}
|
||||||
45
internal/model/media.go
Normal file
45
internal/model/media.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Media struct {
|
||||||
|
Type string `json:"type"` // 媒体类型:image, video, voice, file
|
||||||
|
Key string `json:"key"` // MD5
|
||||||
|
Path string `json:"path"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
Data []byte `json:"data"` // for voice
|
||||||
|
ModifyTime int64 `json:"modifyTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MediaV3 struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Dir1 string `json:"dir1"`
|
||||||
|
Dir2 string `json:"dir2"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ModifyTime int64 `json:"modifyTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaV3) Wrap() *Media {
|
||||||
|
|
||||||
|
var path string
|
||||||
|
switch m.Type {
|
||||||
|
case "image":
|
||||||
|
path = filepath.Join("FileStorage", "MsgAttach", m.Dir1, "Image", m.Dir2, m.Name)
|
||||||
|
case "video":
|
||||||
|
path = filepath.Join("FileStorage", "Video", m.Dir2, m.Name)
|
||||||
|
case "file":
|
||||||
|
path = filepath.Join("FileStorage", "File", m.Dir2, m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Media{
|
||||||
|
Type: m.Type,
|
||||||
|
Key: m.Key,
|
||||||
|
ModifyTime: m.ModifyTime,
|
||||||
|
Path: path,
|
||||||
|
Name: m.Name,
|
||||||
|
}
|
||||||
|
}
|
||||||
40
internal/model/media_darwinv3.go
Normal file
40
internal/model/media_darwinv3.go
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "path/filepath"
|
||||||
|
|
||||||
|
// CREATE TABLE HlinkMediaRecord(
|
||||||
|
// mediaMd5 TEXT,
|
||||||
|
// mediaSize INTEGER,
|
||||||
|
// inodeNumber INTEGER,
|
||||||
|
// modifyTime INTEGER ,
|
||||||
|
// CONSTRAINT _Md5_Size UNIQUE (mediaMd5,mediaSize)
|
||||||
|
// )
|
||||||
|
// CREATE TABLE HlinkMediaDetail(
|
||||||
|
// localId INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
// inodeNumber INTEGER,
|
||||||
|
// relativePath TEXT,
|
||||||
|
// fileName TEXT
|
||||||
|
// )
|
||||||
|
type MediaDarwinV3 struct {
|
||||||
|
MediaMd5 string `json:"mediaMd5"`
|
||||||
|
MediaSize int64 `json:"mediaSize"`
|
||||||
|
InodeNumber int64 `json:"inodeNumber"`
|
||||||
|
ModifyTime int64 `json:"modifyTime"`
|
||||||
|
RelativePath string `json:"relativePath"`
|
||||||
|
FileName string `json:"fileName"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaDarwinV3) Wrap() *Media {
|
||||||
|
|
||||||
|
path := filepath.Join("Message/MessageTemp", m.RelativePath, m.FileName)
|
||||||
|
name := filepath.Base(path)
|
||||||
|
|
||||||
|
return &Media{
|
||||||
|
Type: "",
|
||||||
|
Key: m.MediaMd5,
|
||||||
|
Size: m.MediaSize,
|
||||||
|
ModifyTime: m.ModifyTime,
|
||||||
|
Path: path,
|
||||||
|
Name: name,
|
||||||
|
}
|
||||||
|
}
|
||||||
35
internal/model/media_v4.go
Normal file
35
internal/model/media_v4.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "path/filepath"
|
||||||
|
|
||||||
|
type MediaV4 struct {
|
||||||
|
Type string `json:"type"`
|
||||||
|
Key string `json:"key"`
|
||||||
|
Dir1 string `json:"dir1"`
|
||||||
|
Dir2 string `json:"dir2"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
Size int64 `json:"size"`
|
||||||
|
ModifyTime int64 `json:"modifyTime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MediaV4) Wrap() *Media {
|
||||||
|
|
||||||
|
var path string
|
||||||
|
switch m.Type {
|
||||||
|
case "image":
|
||||||
|
path = filepath.Join("msg", "attach", m.Dir1, m.Dir2, "Img", m.Name)
|
||||||
|
case "video":
|
||||||
|
path = filepath.Join("msg", "video", m.Dir1, m.Name)
|
||||||
|
case "file":
|
||||||
|
path = filepath.Join("msg", "file", m.Dir1, m.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Media{
|
||||||
|
Type: m.Type,
|
||||||
|
Key: m.Key,
|
||||||
|
Path: path,
|
||||||
|
Name: m.Name,
|
||||||
|
Size: m.Size,
|
||||||
|
ModifyTime: m.ModifyTime,
|
||||||
|
}
|
||||||
|
}
|
||||||
424
internal/model/mediamessage.go
Normal file
424
internal/model/mediamessage.go
Normal file
@@ -0,0 +1,424 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MediaMsg struct {
|
||||||
|
XMLName xml.Name `xml:"msg"`
|
||||||
|
Image Image `xml:"img,omitempty"`
|
||||||
|
Video Video `xml:"videomsg,omitempty"`
|
||||||
|
App App `xml:"appmsg,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Image struct {
|
||||||
|
MD5 string `xml:"md5,attr"`
|
||||||
|
// HdLength string `xml:"hdlength,attr"`
|
||||||
|
// Length string `xml:"length,attr"`
|
||||||
|
// AesKey string `xml:"aeskey,attr"`
|
||||||
|
// EncryVer string `xml:"encryver,attr"`
|
||||||
|
// OriginSourceMd5 string `xml:"originsourcemd5,attr"`
|
||||||
|
// FileKey string `xml:"filekey,attr"`
|
||||||
|
// UploadContinueCount string `xml:"uploadcontinuecount,attr"`
|
||||||
|
// ImgSourceUrl string `xml:"imgsourceurl,attr"`
|
||||||
|
// HevcMidSize string `xml:"hevc_mid_size,attr"`
|
||||||
|
// CdnBigImgUrl string `xml:"cdnbigimgurl,attr"`
|
||||||
|
// CdnMidImgUrl string `xml:"cdnmidimgurl,attr"`
|
||||||
|
// CdnThumbUrl string `xml:"cdnthumburl,attr"`
|
||||||
|
// CdnThumbLength string `xml:"cdnthumblength,attr"`
|
||||||
|
// CdnThumbWidth string `xml:"cdnthumbwidth,attr"`
|
||||||
|
// CdnThumbHeight string `xml:"cdnthumbheight,attr"`
|
||||||
|
// CdnThumbAesKey string `xml:"cdnthumbaeskey,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Video struct {
|
||||||
|
Md5 string `xml:"md5,attr"`
|
||||||
|
RawMd5 string `xml:"rawmd5,attr"`
|
||||||
|
// Length string `xml:"length,attr"`
|
||||||
|
// PlayLength string `xml:"playlength,attr"`
|
||||||
|
// Offset string `xml:"offset,attr"`
|
||||||
|
// FromUserName string `xml:"fromusername,attr"`
|
||||||
|
// Status string `xml:"status,attr"`
|
||||||
|
// Compress string `xml:"compress,attr"`
|
||||||
|
// CameraType string `xml:"cameratype,attr"`
|
||||||
|
// Source string `xml:"source,attr"`
|
||||||
|
// AesKey string `xml:"aeskey,attr"`
|
||||||
|
// CdnVideoUrl string `xml:"cdnvideourl,attr"`
|
||||||
|
// CdnThumbUrl string `xml:"cdnthumburl,attr"`
|
||||||
|
// CdnThumbLength string `xml:"cdnthumblength,attr"`
|
||||||
|
// CdnThumbWidth string `xml:"cdnthumbwidth,attr"`
|
||||||
|
// CdnThumbHeight string `xml:"cdnthumbheight,attr"`
|
||||||
|
// CdnThumbAesKey string `xml:"cdnthumbaeskey,attr"`
|
||||||
|
// EncryVer string `xml:"encryver,attr"`
|
||||||
|
// RawLength string `xml:"rawlength,attr"`
|
||||||
|
// CdnRawVideoUrl string `xml:"cdnrawvideourl,attr"`
|
||||||
|
// CdnRawVideoAesKey string `xml:"cdnrawvideoaeskey,attr"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type App struct {
|
||||||
|
Type int `xml:"type"`
|
||||||
|
Title string `xml:"title"`
|
||||||
|
Des string `xml:"des"`
|
||||||
|
URL string `xml:"url"` // type 5 分享
|
||||||
|
AppAttach *AppAttach `xml:"appattach,omitempty"` // type 6 文件
|
||||||
|
MD5 string `xml:"md5,omitempty"` // type 6 文件
|
||||||
|
RecordItem *RecordItem `xml:"recorditem,omitempty"` // type 19 合并转发
|
||||||
|
SourceDisplayName string `xml:"sourcedisplayname,omitempty"` // type 33 小程序
|
||||||
|
FinderFeed *FinderFeed `xml:"finderFeed,omitempty"` // type 51 视频号
|
||||||
|
ReferMsg *ReferMsg `xml:"refermsg,omitempty"` // type 57 引用
|
||||||
|
PatMsg *PatMsg `xml:"patMsg,omitempty"` // type 62 拍一拍
|
||||||
|
WCPayInfo *WCPayInfo `xml:"wcpayinfo,omitempty"` // type 2000 微信转账
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReferMsg 表示引用消息
|
||||||
|
type ReferMsg struct {
|
||||||
|
Type int64 `xml:"type"`
|
||||||
|
SvrID string `xml:"svrid"`
|
||||||
|
FromUsr string `xml:"fromusr"`
|
||||||
|
ChatUsr string `xml:"chatusr"`
|
||||||
|
DisplayName string `xml:"displayname"`
|
||||||
|
MsgSource string `xml:"msgsource"`
|
||||||
|
Content string `xml:"content"`
|
||||||
|
StrID string `xml:"strid"`
|
||||||
|
CreateTime int64 `xml:"createtime"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// AppAttach 表示应用附件
|
||||||
|
type AppAttach struct {
|
||||||
|
TotalLen string `xml:"totallen"`
|
||||||
|
AttachID string `xml:"attachid"`
|
||||||
|
CDNAttachURL string `xml:"cdnattachurl"`
|
||||||
|
EmoticonMD5 string `xml:"emoticonmd5"`
|
||||||
|
AESKey string `xml:"aeskey"`
|
||||||
|
FileExt string `xml:"fileext"`
|
||||||
|
IsLargeFileMsg string `xml:"islargefilemsg"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordItem struct {
|
||||||
|
CDATA string `xml:",cdata"`
|
||||||
|
|
||||||
|
// 解析后的记录信息
|
||||||
|
RecordInfo *RecordInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
// RecordInfo 表示聊天记录信息
|
||||||
|
type RecordInfo struct {
|
||||||
|
XMLName xml.Name `xml:"recordinfo"`
|
||||||
|
FromScene string `xml:"fromscene,omitempty"`
|
||||||
|
FavUsername string `xml:"favusername,omitempty"`
|
||||||
|
FavCreateTime string `xml:"favcreatetime,omitempty"`
|
||||||
|
IsChatRoom string `xml:"isChatRoom,omitempty"`
|
||||||
|
Title string `xml:"title,omitempty"`
|
||||||
|
Desc string `xml:"desc,omitempty"`
|
||||||
|
Info string `xml:"info,omitempty"`
|
||||||
|
DataList DataList `xml:"datalist,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataList 表示数据列表
|
||||||
|
type DataList struct {
|
||||||
|
Count string `xml:"count,attr,omitempty"`
|
||||||
|
DataItems []DataItem `xml:"dataitem,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// DataItem 表示数据项
|
||||||
|
type DataItem struct {
|
||||||
|
DataType string `xml:"datatype,attr,omitempty"`
|
||||||
|
DataID string `xml:"dataid,attr,omitempty"`
|
||||||
|
HTMLID string `xml:"htmlid,attr,omitempty"`
|
||||||
|
DataFmt string `xml:"datafmt,omitempty"`
|
||||||
|
SourceName string `xml:"sourcename,omitempty"`
|
||||||
|
SourceTime string `xml:"sourcetime,omitempty"`
|
||||||
|
SourceHeadURL string `xml:"sourceheadurl,omitempty"`
|
||||||
|
DataDesc string `xml:"datadesc,omitempty"`
|
||||||
|
|
||||||
|
// 图片特有字段
|
||||||
|
ThumbSourcePath string `xml:"thumbsourcepath,omitempty"`
|
||||||
|
ThumbSize string `xml:"thumbsize,omitempty"`
|
||||||
|
CDNDataURL string `xml:"cdndataurl,omitempty"`
|
||||||
|
CDNDataKey string `xml:"cdndatakey,omitempty"`
|
||||||
|
CDNThumbURL string `xml:"cdnthumburl,omitempty"`
|
||||||
|
CDNThumbKey string `xml:"cdnthumbkey,omitempty"`
|
||||||
|
DataSourcePath string `xml:"datasourcepath,omitempty"`
|
||||||
|
FullMD5 string `xml:"fullmd5,omitempty"`
|
||||||
|
ThumbFullMD5 string `xml:"thumbfullmd5,omitempty"`
|
||||||
|
ThumbHead256MD5 string `xml:"thumbhead256md5,omitempty"`
|
||||||
|
DataSize string `xml:"datasize,omitempty"`
|
||||||
|
CDNEncryVer string `xml:"cdnencryver,omitempty"`
|
||||||
|
SrcChatname string `xml:"srcChatname,omitempty"`
|
||||||
|
SrcMsgLocalID string `xml:"srcMsgLocalid,omitempty"`
|
||||||
|
SrcMsgCreateTime string `xml:"srcMsgCreateTime,omitempty"`
|
||||||
|
MessageUUID string `xml:"messageuuid,omitempty"`
|
||||||
|
FromNewMsgID string `xml:"fromnewmsgid,omitempty"`
|
||||||
|
|
||||||
|
// 套娃合并转发
|
||||||
|
DataTitle string `xml:"datatitle,omitempty"`
|
||||||
|
RecordXML *RecordXML `xml:"recordxml,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type RecordXML struct {
|
||||||
|
RecordInfo RecordInfo `xml:"recordinfo,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *RecordInfo) String(title, host string) string {
|
||||||
|
buf := strings.Builder{}
|
||||||
|
if title == "" {
|
||||||
|
title = r.Title
|
||||||
|
}
|
||||||
|
buf.WriteString(fmt.Sprintf("[合并转发|%s]\n", title))
|
||||||
|
for _, item := range r.DataList.DataItems {
|
||||||
|
buf.WriteString(fmt.Sprintf(" %s %s\n", item.SourceName, item.SourceTime))
|
||||||
|
|
||||||
|
// 套娃合并转发
|
||||||
|
if item.DataType == "17" && item.RecordXML != nil {
|
||||||
|
content := item.RecordXML.RecordInfo.String(item.DataTitle, host)
|
||||||
|
if content != "" {
|
||||||
|
for _, line := range strings.Split(content, "\n") {
|
||||||
|
buf.WriteString(fmt.Sprintf(" %s\n", line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
switch item.DataFmt {
|
||||||
|
case "pic", "jpg":
|
||||||
|
buf.WriteString(fmt.Sprintf(" \n", host, item.FullMD5))
|
||||||
|
default:
|
||||||
|
for _, line := range strings.Split(item.DataDesc, "\n") {
|
||||||
|
buf.WriteString(fmt.Sprintf(" %s\n", line))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatMsg 拍一拍消息结构
|
||||||
|
type PatMsg struct {
|
||||||
|
ChatUser string `xml:"chatUser"` // 被拍的用户
|
||||||
|
RecordNum int `xml:"recordNum"` // 记录数量
|
||||||
|
Records Records `xml:"records"` // 拍一拍记录
|
||||||
|
}
|
||||||
|
|
||||||
|
// Records 拍一拍记录集合
|
||||||
|
type Records struct {
|
||||||
|
Record []PatRecord `xml:"record"` // 拍一拍记录列表
|
||||||
|
}
|
||||||
|
|
||||||
|
// PatRecord 单条拍一拍记录
|
||||||
|
type PatRecord struct {
|
||||||
|
FromUser string `xml:"fromUser"` // 发起拍一拍的用户
|
||||||
|
PattedUser string `xml:"pattedUser"` // 被拍的用户
|
||||||
|
Templete string `xml:"templete"` // 模板文本
|
||||||
|
CreateTime int64 `xml:"createTime"` // 创建时间
|
||||||
|
SvrId string `xml:"svrId"` // 服务器ID
|
||||||
|
ReadStatus int `xml:"readStatus"` // 已读状态
|
||||||
|
}
|
||||||
|
|
||||||
|
// WCPayInfo 微信支付信息
|
||||||
|
type WCPayInfo struct {
|
||||||
|
PaySubType int `xml:"paysubtype"` // 支付子类型
|
||||||
|
FeeDesc string `xml:"feedesc"` // 金额描述,如"¥200000.00"
|
||||||
|
TranscationID string `xml:"transcationid"` // 交易ID
|
||||||
|
TransferID string `xml:"transferid"` // 转账ID
|
||||||
|
InvalidTime string `xml:"invalidtime"` // 失效时间
|
||||||
|
BeginTransferTime string `xml:"begintransfertime"` // 开始转账时间
|
||||||
|
EffectiveDate string `xml:"effectivedate"` // 生效日期
|
||||||
|
PayMemo string `xml:"pay_memo"` // 支付备注
|
||||||
|
ReceiverUsername string `xml:"receiver_username"` // 接收方用户名
|
||||||
|
PayerUsername string `xml:"payer_username"` // 支付方用户名
|
||||||
|
}
|
||||||
|
|
||||||
|
// FinderFeed 视频号信息
|
||||||
|
type FinderFeed struct {
|
||||||
|
ObjectID string `xml:"objectId"`
|
||||||
|
FeedType string `xml:"feedType"`
|
||||||
|
Nickname string `xml:"nickname"`
|
||||||
|
Avatar string `xml:"avatar"`
|
||||||
|
Desc string `xml:"desc"`
|
||||||
|
MediaCount string `xml:"mediaCount"`
|
||||||
|
ObjectNonceID string `xml:"objectNonceId"`
|
||||||
|
LiveID string `xml:"liveId"`
|
||||||
|
Username string `xml:"username"`
|
||||||
|
AuthIconURL string `xml:"authIconUrl"`
|
||||||
|
AuthIconType int `xml:"authIconType"`
|
||||||
|
ContactJumpInfoStr string `xml:"contactJumpInfoStr"`
|
||||||
|
SourceCommentScene int `xml:"sourceCommentScene"`
|
||||||
|
MediaList FinderMediaList `xml:"mediaList"`
|
||||||
|
MegaVideo FinderMegaVideo `xml:"megaVideo"`
|
||||||
|
BizUsername string `xml:"bizUsername"`
|
||||||
|
BizNickname string `xml:"bizNickname"`
|
||||||
|
BizAvatar string `xml:"bizAvatar"`
|
||||||
|
BizUsernameV2 string `xml:"bizUsernameV2"`
|
||||||
|
BizAuthIconURL string `xml:"bizAuthIconUrl"`
|
||||||
|
BizAuthIconType int `xml:"bizAuthIconType"`
|
||||||
|
EcSource string `xml:"ecSource"`
|
||||||
|
LastGMsgID string `xml:"lastGMsgID"`
|
||||||
|
ShareBypData string `xml:"shareBypData"`
|
||||||
|
IsDebug int `xml:"isDebug"`
|
||||||
|
ContentType int `xml:"content_type"`
|
||||||
|
FinderForwardSource string `xml:"finderForwardSource"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinderMediaList struct {
|
||||||
|
Media []FinderMedia `xml:"media"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinderMedia struct {
|
||||||
|
ThumbURL string `xml:"thumbUrl"`
|
||||||
|
FullCoverURL string `xml:"fullCoverUrl"`
|
||||||
|
VideoPlayDuration string `xml:"videoPlayDuration"`
|
||||||
|
URL string `xml:"url"`
|
||||||
|
CoverURL string `xml:"coverUrl"`
|
||||||
|
Height string `xml:"height"`
|
||||||
|
MediaType string `xml:"mediaType"`
|
||||||
|
FullClipInset string `xml:"fullClipInset"`
|
||||||
|
Width string `xml:"width"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type FinderMegaVideo struct {
|
||||||
|
ObjectID string `xml:"objectId"`
|
||||||
|
ObjectNonceID string `xml:"objectNonceId"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type SysMsg struct {
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
DelChatRoomMember *DelChatRoomMember `xml:"delchatroommember,omitempty"`
|
||||||
|
SysMsgTemplate *SysMsgTemplate `xml:"sysmsgtemplate,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第一种消息类型:删除群成员/二维码邀请
|
||||||
|
type DelChatRoomMember struct {
|
||||||
|
Plain string `xml:"plain"`
|
||||||
|
Text string `xml:"text"`
|
||||||
|
Link QRLink `xml:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QRLink struct {
|
||||||
|
Scene string `xml:"scene"`
|
||||||
|
Text string `xml:"text"`
|
||||||
|
MemberList QRMemberList `xml:"memberlist"`
|
||||||
|
QRCode string `xml:"qrcode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type QRMemberList struct {
|
||||||
|
Usernames []UsernameItem `xml:"username"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type UsernameItem struct {
|
||||||
|
Value string `xml:",chardata"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 第二种消息类型:系统消息模板
|
||||||
|
type SysMsgTemplate struct {
|
||||||
|
ContentTemplate ContentTemplate `xml:"content_template"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ContentTemplate struct {
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
Plain string `xml:"plain"`
|
||||||
|
Template string `xml:"template"`
|
||||||
|
LinkList LinkList `xml:"link_list"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type LinkList struct {
|
||||||
|
Links []Link `xml:"link"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Link struct {
|
||||||
|
Name string `xml:"name,attr"`
|
||||||
|
Type string `xml:"type,attr"`
|
||||||
|
MemberList MemberList `xml:"memberlist"`
|
||||||
|
Separator string `xml:"separator,omitempty"`
|
||||||
|
Title string `xml:"title,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type MemberList struct {
|
||||||
|
Members []Member `xml:"member"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type Member struct {
|
||||||
|
Username string `xml:"username"`
|
||||||
|
Nickname string `xml:"nickname"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SysMsg) String() string {
|
||||||
|
if s.Type == "delchatroommember" {
|
||||||
|
return s.DelChatRoomMemberString()
|
||||||
|
}
|
||||||
|
return s.SysMsgTemplateString()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SysMsg) DelChatRoomMemberString() string {
|
||||||
|
if s.DelChatRoomMember == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.DelChatRoomMember.Plain
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SysMsg) SysMsgTemplateString() string {
|
||||||
|
if s.SysMsgTemplate == nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
template := s.SysMsgTemplate.ContentTemplate.Template
|
||||||
|
links := s.SysMsgTemplate.ContentTemplate.LinkList.Links
|
||||||
|
|
||||||
|
// 创建一个映射,用于存储占位符名称和对应的替换内容
|
||||||
|
replacements := make(map[string]string)
|
||||||
|
|
||||||
|
// 遍历所有链接,为每个占位符准备替换内容
|
||||||
|
for _, link := range links {
|
||||||
|
var replacement string
|
||||||
|
|
||||||
|
// 根据链接类型和成员信息生成替换内容
|
||||||
|
switch link.Type {
|
||||||
|
case "link_profile":
|
||||||
|
// 使用自定义分隔符,如果未指定则默认使用"、"
|
||||||
|
separator := link.Separator
|
||||||
|
if separator == "" {
|
||||||
|
separator = "、"
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理成员信息,格式为 nickname(username)
|
||||||
|
var memberTexts []string
|
||||||
|
for _, member := range link.MemberList.Members {
|
||||||
|
if member.Nickname != "" {
|
||||||
|
memberText := member.Nickname
|
||||||
|
if member.Username != "" {
|
||||||
|
memberText += "(" + member.Username + ")"
|
||||||
|
}
|
||||||
|
memberTexts = append(memberTexts, memberText)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用指定的分隔符连接所有成员文本
|
||||||
|
replacement = strings.Join(memberTexts, separator)
|
||||||
|
|
||||||
|
// 可以根据需要添加其他链接类型的处理逻辑
|
||||||
|
default:
|
||||||
|
if link.Title != "" {
|
||||||
|
replacement = link.Title
|
||||||
|
} else {
|
||||||
|
replacement = ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将占位符名称和替换内容存入映射
|
||||||
|
replacements["$"+link.Name+"$"] = replacement
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用正则表达式查找并替换所有占位符
|
||||||
|
re := regexp.MustCompile(`\$([^$]+)\$`)
|
||||||
|
result := re.ReplaceAllStringFunc(template, func(match string) string {
|
||||||
|
if replacement, ok := replacements[match]; ok {
|
||||||
|
return replacement
|
||||||
|
}
|
||||||
|
// 如果找不到对应的替换内容,保留原占位符
|
||||||
|
return match
|
||||||
|
})
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
381
internal/model/message.go
Normal file
381
internal/model/message.go
Normal file
@@ -0,0 +1,381 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Debug = false
|
||||||
|
|
||||||
|
const (
|
||||||
|
WeChatV3 = "wechatv3"
|
||||||
|
WeChatV4 = "wechatv4"
|
||||||
|
WeChatDarwinV3 = "wechatdarwinv3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Message struct {
|
||||||
|
Version string `json:"-"` // 消息版本,内部判断
|
||||||
|
Seq int64 `json:"seq"` // 消息序号,10位时间戳 + 3位序号
|
||||||
|
Time time.Time `json:"time"` // 消息创建时间,10位时间戳
|
||||||
|
Talker string `json:"talker"` // 聊天对象,微信 ID or 群 ID
|
||||||
|
TalkerName string `json:"talkerName"` // 聊天对象名称
|
||||||
|
IsChatRoom bool `json:"isChatRoom"` // 是否为群聊消息
|
||||||
|
Sender string `json:"sender"` // 发送人,微信 ID
|
||||||
|
SenderName string `json:"senderName"` // 发送人名称
|
||||||
|
IsSelf bool `json:"isSelf"` // 是否为自己发送的消息
|
||||||
|
Type int64 `json:"type"` // 消息类型
|
||||||
|
SubType int64 `json:"subType"` // 消息子类型
|
||||||
|
Content string `json:"content"` // 消息内容,文字聊天内容
|
||||||
|
Contents map[string]interface{} `json:"contents,omitempty"` // 消息内容,多媒体消息,采用更灵活的记录方式
|
||||||
|
|
||||||
|
// Debug Info
|
||||||
|
MediaMsg *MediaMsg `json:"mediaMsg,omitempty"` // 原始多媒体消息,XML 格式
|
||||||
|
SysMsg *SysMsg `json:"sysMsg,omitempty"` // 原始系统消息,XML 格式
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) ParseMediaInfo(data string) error {
|
||||||
|
|
||||||
|
m.Type, m.SubType = util.SplitInt64ToTwoInt32(m.Type)
|
||||||
|
|
||||||
|
if m.Type == 1 {
|
||||||
|
m.Content = data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Type == 10000 {
|
||||||
|
var sysMsg SysMsg
|
||||||
|
if err := xml.Unmarshal([]byte(data), &sysMsg); err != nil {
|
||||||
|
m.Content = data
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if Debug {
|
||||||
|
m.SysMsg = &sysMsg
|
||||||
|
}
|
||||||
|
m.Sender = "系统消息"
|
||||||
|
m.SenderName = ""
|
||||||
|
m.Content = sysMsg.String()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var msg MediaMsg
|
||||||
|
err := xml.Unmarshal([]byte(data), &msg)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
if m.Contents == nil {
|
||||||
|
m.Contents = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
|
||||||
|
if Debug {
|
||||||
|
m.MediaMsg = &msg
|
||||||
|
}
|
||||||
|
|
||||||
|
switch m.Type {
|
||||||
|
case 3:
|
||||||
|
m.Contents["md5"] = msg.Image.MD5
|
||||||
|
case 43:
|
||||||
|
if msg.Video.Md5 != "" {
|
||||||
|
m.Contents["md5"] = msg.Video.Md5
|
||||||
|
}
|
||||||
|
if msg.Video.RawMd5 != "" {
|
||||||
|
m.Contents["rawmd5"] = msg.Video.RawMd5
|
||||||
|
}
|
||||||
|
case 49:
|
||||||
|
m.SubType = int64(msg.App.Type)
|
||||||
|
switch m.SubType {
|
||||||
|
case 5:
|
||||||
|
// 链接
|
||||||
|
m.Contents["title"] = msg.App.Title
|
||||||
|
m.Contents["url"] = msg.App.URL
|
||||||
|
case 6:
|
||||||
|
// 文件
|
||||||
|
m.Contents["title"] = msg.App.Title
|
||||||
|
m.Contents["md5"] = msg.App.MD5
|
||||||
|
case 19:
|
||||||
|
// 合并转发
|
||||||
|
m.Contents["title"] = msg.App.Title
|
||||||
|
m.Contents["desc"] = msg.App.Des
|
||||||
|
if msg.App.RecordItem == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
recordInfo := &RecordInfo{}
|
||||||
|
err := xml.Unmarshal([]byte(msg.App.RecordItem.CDATA), recordInfo)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
m.Contents["recordInfo"] = recordInfo
|
||||||
|
case 33, 36:
|
||||||
|
// 小程序
|
||||||
|
m.Contents["title"] = msg.App.SourceDisplayName
|
||||||
|
m.Contents["url"] = msg.App.URL
|
||||||
|
case 51:
|
||||||
|
// 视频号
|
||||||
|
if msg.App.FinderFeed == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
m.Contents["title"] = msg.App.FinderFeed.Desc
|
||||||
|
if len(msg.App.FinderFeed.MediaList.Media) > 0 {
|
||||||
|
m.Contents["url"] = msg.App.FinderFeed.MediaList.Media[0].URL
|
||||||
|
}
|
||||||
|
case 57:
|
||||||
|
// 引用
|
||||||
|
m.Content = msg.App.Title
|
||||||
|
if msg.App.ReferMsg == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
subMsg := &Message{
|
||||||
|
Type: int64(msg.App.ReferMsg.Type),
|
||||||
|
Time: time.Unix(msg.App.ReferMsg.CreateTime, 0),
|
||||||
|
Sender: msg.App.ReferMsg.ChatUsr,
|
||||||
|
SenderName: msg.App.ReferMsg.DisplayName,
|
||||||
|
}
|
||||||
|
if subMsg.Sender == "" {
|
||||||
|
subMsg.Sender = msg.App.ReferMsg.FromUsr
|
||||||
|
}
|
||||||
|
if err := subMsg.ParseMediaInfo(msg.App.ReferMsg.Content); err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
m.Contents["refer"] = subMsg
|
||||||
|
case 62:
|
||||||
|
// 拍一拍
|
||||||
|
if msg.App.PatMsg == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
if len(msg.App.PatMsg.Records.Record) == 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
m.Sender = msg.App.PatMsg.Records.Record[0].FromUser
|
||||||
|
m.Content = msg.App.PatMsg.Records.Record[0].Templete
|
||||||
|
case 2000:
|
||||||
|
// 微信转账
|
||||||
|
if msg.App.WCPayInfo == nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
// 1 实时转账
|
||||||
|
// 3 实时转账收钱回执
|
||||||
|
// 4 转账退还回执
|
||||||
|
// 5 非实时转账收钱回执
|
||||||
|
// 7 非实时转账
|
||||||
|
_type := ""
|
||||||
|
switch msg.App.WCPayInfo.PaySubType {
|
||||||
|
case 1, 7:
|
||||||
|
_type = "发送 "
|
||||||
|
case 3, 5:
|
||||||
|
_type = "接收 "
|
||||||
|
case 4:
|
||||||
|
_type = "退还 "
|
||||||
|
}
|
||||||
|
payMemo := ""
|
||||||
|
if len(msg.App.WCPayInfo.PayMemo) > 0 {
|
||||||
|
payMemo = "(" + msg.App.WCPayInfo.PayMemo + ")"
|
||||||
|
}
|
||||||
|
m.Content = fmt.Sprintf("[转账|%s%s]%s", _type, msg.App.WCPayInfo.FeeDesc, payMemo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) SetContent(key string, value interface{}) {
|
||||||
|
if m.Contents == nil {
|
||||||
|
m.Contents = make(map[string]interface{})
|
||||||
|
}
|
||||||
|
m.Contents[key] = value
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) PlainText(showChatRoom bool, timeFormat string, host string) string {
|
||||||
|
|
||||||
|
if timeFormat == "" {
|
||||||
|
timeFormat = "01-02 15:04:05"
|
||||||
|
}
|
||||||
|
|
||||||
|
m.SetContent("host", host)
|
||||||
|
|
||||||
|
buf := strings.Builder{}
|
||||||
|
|
||||||
|
sender := m.Sender
|
||||||
|
if m.IsSelf {
|
||||||
|
sender = "我"
|
||||||
|
}
|
||||||
|
if m.SenderName != "" {
|
||||||
|
buf.WriteString(m.SenderName)
|
||||||
|
buf.WriteString("(")
|
||||||
|
buf.WriteString(sender)
|
||||||
|
buf.WriteString(")")
|
||||||
|
} else {
|
||||||
|
buf.WriteString(sender)
|
||||||
|
}
|
||||||
|
buf.WriteString(" ")
|
||||||
|
|
||||||
|
if m.IsChatRoom && showChatRoom {
|
||||||
|
buf.WriteString("[")
|
||||||
|
if m.TalkerName != "" {
|
||||||
|
buf.WriteString(m.TalkerName)
|
||||||
|
buf.WriteString("(")
|
||||||
|
buf.WriteString(m.Talker)
|
||||||
|
buf.WriteString(")")
|
||||||
|
} else {
|
||||||
|
buf.WriteString(m.Talker)
|
||||||
|
}
|
||||||
|
buf.WriteString("] ")
|
||||||
|
}
|
||||||
|
|
||||||
|
buf.WriteString(m.Time.Format(timeFormat))
|
||||||
|
buf.WriteString("\n")
|
||||||
|
|
||||||
|
buf.WriteString(m.PlainTextContent())
|
||||||
|
buf.WriteString("\n")
|
||||||
|
|
||||||
|
return buf.String()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Message) PlainTextContent() string {
|
||||||
|
switch m.Type {
|
||||||
|
case 1:
|
||||||
|
return m.Content
|
||||||
|
case 3:
|
||||||
|
keylist := make([]string, 0)
|
||||||
|
if m.Contents["md5"] != nil {
|
||||||
|
if md5, ok := m.Contents["md5"].(string); ok {
|
||||||
|
keylist = append(keylist, md5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Contents["imgfile"] != nil {
|
||||||
|
if imgfile, ok := m.Contents["imgfile"].(string); ok {
|
||||||
|
keylist = append(keylist, imgfile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Contents["thumb"] != nil {
|
||||||
|
if thumb, ok := m.Contents["thumb"].(string); ok {
|
||||||
|
keylist = append(keylist, thumb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("", m.Contents["host"], strings.Join(keylist, ","))
|
||||||
|
case 34:
|
||||||
|
if voice, ok := m.Contents["voice"]; ok {
|
||||||
|
return fmt.Sprintf("[语音](http://%s/voice/%s)", m.Contents["host"], voice)
|
||||||
|
}
|
||||||
|
return "[语音]"
|
||||||
|
case 42:
|
||||||
|
return "[名片]"
|
||||||
|
case 43:
|
||||||
|
keylist := make([]string, 0)
|
||||||
|
if m.Contents["md5"] != nil {
|
||||||
|
if md5, ok := m.Contents["md5"].(string); ok {
|
||||||
|
keylist = append(keylist, md5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Contents["rawmd5"] != nil {
|
||||||
|
if rawmd5, ok := m.Contents["rawmd5"].(string); ok {
|
||||||
|
keylist = append(keylist, rawmd5)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Contents["videofile"] != nil {
|
||||||
|
if videofile, ok := m.Contents["videofile"].(string); ok {
|
||||||
|
keylist = append(keylist, videofile)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if m.Contents["thumb"] != nil {
|
||||||
|
if thumb, ok := m.Contents["thumb"].(string); ok {
|
||||||
|
keylist = append(keylist, thumb)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("", m.Contents["host"], strings.Join(keylist, ","))
|
||||||
|
case 47:
|
||||||
|
return "[动画表情]"
|
||||||
|
case 49:
|
||||||
|
switch m.SubType {
|
||||||
|
case 5:
|
||||||
|
return fmt.Sprintf("[链接|%s](%s)", m.Contents["title"], m.Contents["url"])
|
||||||
|
case 6:
|
||||||
|
return fmt.Sprintf("[文件|%s](http://%s/file/%s)", m.Contents["title"], m.Contents["host"], m.Contents["md5"])
|
||||||
|
case 8:
|
||||||
|
return "[GIF表情]"
|
||||||
|
case 19:
|
||||||
|
_recordInfo, ok := m.Contents["recordInfo"]
|
||||||
|
if !ok {
|
||||||
|
return "[合并转发]"
|
||||||
|
}
|
||||||
|
recordInfo, ok := _recordInfo.(*RecordInfo)
|
||||||
|
if !ok {
|
||||||
|
return "[合并转发]"
|
||||||
|
}
|
||||||
|
host := ""
|
||||||
|
if m.Contents["host"] != nil {
|
||||||
|
host = m.Contents["host"].(string)
|
||||||
|
}
|
||||||
|
return recordInfo.String("", host)
|
||||||
|
case 33, 36:
|
||||||
|
if m.Contents["title"] == "" {
|
||||||
|
return "[小程序]"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("[小程序|%s](%s)", m.Contents["title"], m.Contents["url"])
|
||||||
|
case 51:
|
||||||
|
if m.Contents["title"] == "" {
|
||||||
|
return "[视频号]"
|
||||||
|
} else {
|
||||||
|
return fmt.Sprintf("[视频号|%s](%s)", m.Contents["title"], m.Contents["url"])
|
||||||
|
}
|
||||||
|
case 57:
|
||||||
|
_refer, ok := m.Contents["refer"]
|
||||||
|
if !ok {
|
||||||
|
if m.Content == "" {
|
||||||
|
return "[引用]"
|
||||||
|
}
|
||||||
|
return "> [引用]\n" + m.Content
|
||||||
|
}
|
||||||
|
refer, ok := _refer.(*Message)
|
||||||
|
if !ok {
|
||||||
|
if m.Content == "" {
|
||||||
|
return "[引用]"
|
||||||
|
}
|
||||||
|
return "> [引用]\n" + m.Content
|
||||||
|
}
|
||||||
|
buf := strings.Builder{}
|
||||||
|
host := ""
|
||||||
|
if m.Contents["host"] != nil {
|
||||||
|
host = m.Contents["host"].(string)
|
||||||
|
}
|
||||||
|
referContent := refer.PlainText(false, "", host)
|
||||||
|
for _, line := range strings.Split(referContent, "\n") {
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
buf.WriteString("> ")
|
||||||
|
buf.WriteString(line)
|
||||||
|
buf.WriteString("\n")
|
||||||
|
}
|
||||||
|
buf.WriteString(m.Content)
|
||||||
|
return buf.String()
|
||||||
|
case 62:
|
||||||
|
return m.Content
|
||||||
|
case 63:
|
||||||
|
return "[视频号]"
|
||||||
|
case 87:
|
||||||
|
return "[群公告]"
|
||||||
|
case 2000:
|
||||||
|
return m.Content
|
||||||
|
case 2001:
|
||||||
|
return "[红包]"
|
||||||
|
case 2003:
|
||||||
|
return "[红包封面]"
|
||||||
|
default:
|
||||||
|
return "[分享]"
|
||||||
|
}
|
||||||
|
case 50:
|
||||||
|
return "[语音通话]"
|
||||||
|
case 10000:
|
||||||
|
return m.Content
|
||||||
|
default:
|
||||||
|
content := m.Content
|
||||||
|
if len(content) > 120 {
|
||||||
|
content = content[:120] + "<...>"
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Type: %d Content: %s", m.Type, content)
|
||||||
|
}
|
||||||
|
}
|
||||||
57
internal/model/message_darwinv3.go
Normal file
57
internal/model/message_darwinv3.go
Normal file
@@ -0,0 +1,57 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CREATE TABLE Chat_md5(talker)(
|
||||||
|
// mesLocalID INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
// mesSvrID INTEGER,msgCreateTime INTEGER,
|
||||||
|
// msgContent TEXT,msgStatus INTEGER,
|
||||||
|
// msgImgStatus INTEGER,
|
||||||
|
// messageType INTEGER,
|
||||||
|
// mesDes INTEGER,
|
||||||
|
// msgSource TEXT,
|
||||||
|
// IntRes1 INTEGER,
|
||||||
|
// IntRes2 INTEGER,
|
||||||
|
// StrRes1 TEXT,
|
||||||
|
// StrRes2 TEXT,
|
||||||
|
// msgVoiceText TEXT,
|
||||||
|
// msgSeq INTEGER,
|
||||||
|
// CompressContent BLOB,
|
||||||
|
// ConBlob BLOB
|
||||||
|
// )
|
||||||
|
type MessageDarwinV3 struct {
|
||||||
|
MsgCreateTime int64 `json:"msgCreateTime"`
|
||||||
|
MsgContent string `json:"msgContent"`
|
||||||
|
MessageType int64 `json:"messageType"`
|
||||||
|
MesDes int `json:"mesDes"` // 0: 发送, 1: 接收
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MessageDarwinV3) Wrap(talker string) *Message {
|
||||||
|
|
||||||
|
_m := &Message{
|
||||||
|
Time: time.Unix(m.MsgCreateTime, 0),
|
||||||
|
Type: m.MessageType,
|
||||||
|
Talker: talker,
|
||||||
|
IsChatRoom: strings.HasSuffix(talker, "@chatroom"),
|
||||||
|
IsSelf: m.MesDes == 0,
|
||||||
|
Version: WeChatDarwinV3,
|
||||||
|
}
|
||||||
|
|
||||||
|
content := m.MsgContent
|
||||||
|
if _m.IsChatRoom {
|
||||||
|
split := strings.SplitN(content, ":\n", 2)
|
||||||
|
if len(split) == 2 {
|
||||||
|
_m.Sender = split[0]
|
||||||
|
content = split[1]
|
||||||
|
}
|
||||||
|
} else if !_m.IsSelf {
|
||||||
|
_m.Sender = talker
|
||||||
|
}
|
||||||
|
|
||||||
|
_m.ParseMediaInfo(content)
|
||||||
|
|
||||||
|
return _m
|
||||||
|
}
|
||||||
124
internal/model/message_v3.go
Normal file
124
internal/model/message_v3.go
Normal file
@@ -0,0 +1,124 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/model/wxproto"
|
||||||
|
"github.com/sjzar/chatlog/pkg/util/lz4"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CREATE TABLE MSG (
|
||||||
|
// localId INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
// TalkerId INT DEFAULT 0,
|
||||||
|
// MsgSvrID INT,
|
||||||
|
// Type INT,
|
||||||
|
// SubType INT,
|
||||||
|
// IsSender INT,
|
||||||
|
// CreateTime INT,
|
||||||
|
// Sequence INT DEFAULT 0,
|
||||||
|
// StatusEx INT DEFAULT 0,
|
||||||
|
// FlagEx INT,
|
||||||
|
// Status INT,
|
||||||
|
// MsgServerSeq INT,
|
||||||
|
// MsgSequence INT,
|
||||||
|
// StrTalker TEXT,
|
||||||
|
// StrContent TEXT,
|
||||||
|
// DisplayContent TEXT,
|
||||||
|
// Reserved0 INT DEFAULT 0,
|
||||||
|
// Reserved1 INT DEFAULT 0,
|
||||||
|
// Reserved2 INT DEFAULT 0,
|
||||||
|
// Reserved3 INT DEFAULT 0,
|
||||||
|
// Reserved4 TEXT,
|
||||||
|
// Reserved5 TEXT,
|
||||||
|
// Reserved6 TEXT,
|
||||||
|
// CompressContent BLOB,
|
||||||
|
// BytesExtra BLOB,
|
||||||
|
// BytesTrans BLOB
|
||||||
|
// )
|
||||||
|
type MessageV3 struct {
|
||||||
|
MsgSvrID int64 `json:"MsgSvrID"` // 消息 ID
|
||||||
|
Sequence int64 `json:"Sequence"` // 消息序号,10位时间戳 + 3位序号
|
||||||
|
CreateTime int64 `json:"CreateTime"` // 消息创建时间,10位时间戳
|
||||||
|
StrTalker string `json:"StrTalker"` // 聊天对象,微信 ID or 群 ID
|
||||||
|
IsSender int `json:"IsSender"` // 是否为发送消息,0 接收消息,1 发送消息
|
||||||
|
Type int64 `json:"Type"` // 消息类型
|
||||||
|
SubType int `json:"SubType"` // 消息子类型
|
||||||
|
StrContent string `json:"StrContent"` // 消息内容,文字聊天内容 或 XML
|
||||||
|
CompressContent []byte `json:"CompressContent"` // 非文字聊天内容,如图片、语音、视频等
|
||||||
|
BytesExtra []byte `json:"BytesExtra"` // protobuf 额外数据,记录群聊发送人等信息
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MessageV3) Wrap() *Message {
|
||||||
|
|
||||||
|
_m := &Message{
|
||||||
|
Seq: m.Sequence,
|
||||||
|
Time: time.Unix(m.CreateTime, 0),
|
||||||
|
Talker: m.StrTalker,
|
||||||
|
IsChatRoom: strings.HasSuffix(m.StrTalker, "@chatroom"),
|
||||||
|
IsSelf: m.IsSender == 1,
|
||||||
|
Type: m.Type,
|
||||||
|
SubType: int64(m.SubType),
|
||||||
|
Content: m.StrContent,
|
||||||
|
Version: WeChatV3,
|
||||||
|
}
|
||||||
|
|
||||||
|
if !_m.IsChatRoom && !_m.IsSelf {
|
||||||
|
_m.Sender = m.StrTalker
|
||||||
|
}
|
||||||
|
|
||||||
|
if _m.Type == 49 {
|
||||||
|
b, err := lz4.Decompress(m.CompressContent)
|
||||||
|
if err == nil {
|
||||||
|
_m.Content = string(b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_m.ParseMediaInfo(_m.Content)
|
||||||
|
|
||||||
|
// 语音消息
|
||||||
|
if _m.Type == 34 {
|
||||||
|
_m.Contents["voice"] = fmt.Sprint(m.MsgSvrID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.BytesExtra) != 0 {
|
||||||
|
if bytesExtra := ParseBytesExtra(m.BytesExtra); bytesExtra != nil {
|
||||||
|
if _m.IsChatRoom {
|
||||||
|
_m.Sender = bytesExtra[1]
|
||||||
|
}
|
||||||
|
// FIXME xml 中的 md5 数据无法匹配到 hardlink 记录,所以直接用 proto 数据
|
||||||
|
if _m.Type == 43 {
|
||||||
|
path := bytesExtra[4]
|
||||||
|
parts := strings.Split(filepath.ToSlash(path), "/")
|
||||||
|
if len(parts) > 1 {
|
||||||
|
path = strings.Join(parts[1:], "/")
|
||||||
|
}
|
||||||
|
_m.Contents["videofile"] = path
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _m
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParseBytesExtra 解析额外数据
|
||||||
|
// 按需解析
|
||||||
|
func ParseBytesExtra(b []byte) map[int]string {
|
||||||
|
var pbMsg wxproto.BytesExtra
|
||||||
|
if err := proto.Unmarshal(b, &pbMsg); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if pbMsg.Items == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
ret := make(map[int]string, len(pbMsg.Items))
|
||||||
|
for _, item := range pbMsg.Items {
|
||||||
|
ret[int(item.Type)] = item.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
return ret
|
||||||
|
}
|
||||||
112
internal/model/message_v4.go
Normal file
112
internal/model/message_v4.go
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/model/wxproto"
|
||||||
|
"github.com/sjzar/chatlog/pkg/util/zstd"
|
||||||
|
"google.golang.org/protobuf/proto"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CREATE TABLE Msg_md5(talker)(
|
||||||
|
// local_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
// server_id INTEGER,
|
||||||
|
// local_type INTEGER,
|
||||||
|
// sort_seq INTEGER,
|
||||||
|
// real_sender_id INTEGER,
|
||||||
|
// create_time INTEGER,
|
||||||
|
// status INTEGER,
|
||||||
|
// upload_status INTEGER,
|
||||||
|
// download_status INTEGER,
|
||||||
|
// server_seq INTEGER,
|
||||||
|
// origin_source INTEGER,
|
||||||
|
// source TEXT,
|
||||||
|
// message_content TEXT,
|
||||||
|
// compress_content TEXT,
|
||||||
|
// packed_info_data BLOB,
|
||||||
|
// WCDB_CT_message_content INTEGER DEFAULT NULL,
|
||||||
|
// WCDB_CT_source INTEGER DEFAULT NULL
|
||||||
|
// )
|
||||||
|
type MessageV4 struct {
|
||||||
|
SortSeq int64 `json:"sort_seq"` // 消息序号,10位时间戳 + 3位序号
|
||||||
|
ServerID int64 `json:"server_id"` // 消息 ID,用于关联 voice
|
||||||
|
LocalType int64 `json:"local_type"` // 消息类型
|
||||||
|
UserName string `json:"user_name"` // 发送人,通过 Join Name2Id 表获得
|
||||||
|
CreateTime int64 `json:"create_time"` // 消息创建时间,10位时间戳
|
||||||
|
MessageContent []byte `json:"message_content"` // 消息内容,文字聊天内容 或 zstd 压缩内容
|
||||||
|
PackedInfoData []byte `json:"packed_info_data"` // 额外数据,类似 proto,格式与 v3 有差异
|
||||||
|
Status int `json:"status"` // 消息状态,2 是已发送,4 是已接收,可以用于判断 IsSender(FIXME 不准, 需要判断 UserName)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *MessageV4) Wrap(talker string) *Message {
|
||||||
|
|
||||||
|
_m := &Message{
|
||||||
|
Seq: m.SortSeq,
|
||||||
|
Time: time.Unix(m.CreateTime, 0),
|
||||||
|
Talker: talker,
|
||||||
|
IsChatRoom: strings.HasSuffix(talker, "@chatroom"),
|
||||||
|
Sender: m.UserName,
|
||||||
|
Type: m.LocalType,
|
||||||
|
Contents: make(map[string]interface{}),
|
||||||
|
Version: WeChatV4,
|
||||||
|
}
|
||||||
|
|
||||||
|
// FIXME 后续通过 UserName 判断是否是自己发送的消息,目前可能不准确
|
||||||
|
_m.IsSelf = m.Status == 2 || (!_m.IsChatRoom && talker != m.UserName)
|
||||||
|
|
||||||
|
content := ""
|
||||||
|
if bytes.HasPrefix(m.MessageContent, []byte{0x28, 0xb5, 0x2f, 0xfd}) {
|
||||||
|
if b, err := zstd.Decompress(m.MessageContent); err == nil {
|
||||||
|
content = string(b)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
content = string(m.MessageContent)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _m.IsChatRoom {
|
||||||
|
split := strings.SplitN(content, ":\n", 2)
|
||||||
|
if len(split) == 2 {
|
||||||
|
_m.Sender = split[0]
|
||||||
|
content = split[1]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
_m.ParseMediaInfo(content)
|
||||||
|
|
||||||
|
// 语音消息
|
||||||
|
if _m.Type == 34 {
|
||||||
|
_m.Contents["voice"] = fmt.Sprint(m.ServerID)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(m.PackedInfoData) != 0 {
|
||||||
|
if packedInfo := ParsePackedInfo(m.PackedInfoData); packedInfo != nil {
|
||||||
|
// FIXME 尝试解决 v4 版本 xml 数据无法匹配到 hardlink 记录的问题
|
||||||
|
if _m.Type == 3 && packedInfo.Image != nil {
|
||||||
|
_talkerMd5Bytes := md5.Sum([]byte(talker))
|
||||||
|
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||||
|
_m.Contents["imgfile"] = filepath.Join("msg", "attach", talkerMd5, _m.Time.Format("2006-01"), "Img", fmt.Sprintf("%s.dat", packedInfo.Image.Md5))
|
||||||
|
_m.Contents["thumb"] = filepath.Join("msg", "attach", talkerMd5, _m.Time.Format("2006-01"), "Img", fmt.Sprintf("%s_t.dat", packedInfo.Image.Md5))
|
||||||
|
}
|
||||||
|
if _m.Type == 43 && packedInfo.Video != nil {
|
||||||
|
_m.Contents["videofile"] = filepath.Join("msg", "video", _m.Time.Format("2006-01"), fmt.Sprintf("%s.mp4", packedInfo.Video.Md5))
|
||||||
|
_m.Contents["thumb"] = filepath.Join("msg", "video", _m.Time.Format("2006-01"), fmt.Sprintf("%s_thumb.jpg", packedInfo.Video.Md5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return _m
|
||||||
|
}
|
||||||
|
|
||||||
|
func ParsePackedInfo(b []byte) *wxproto.PackedInfo {
|
||||||
|
var pbMsg wxproto.PackedInfo
|
||||||
|
if err := proto.Unmarshal(b, &pbMsg); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &pbMsg
|
||||||
|
}
|
||||||
@@ -71,47 +71,6 @@ func (s *SessionV3) Wrap() *Session {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 注意,v4 session 是独立数据库文件
|
|
||||||
// CREATE TABLE SessionTable(
|
|
||||||
// username TEXT PRIMARY KEY,
|
|
||||||
// type INTEGER,
|
|
||||||
// unread_count INTEGER,
|
|
||||||
// unread_first_msg_srv_id INTEGER,
|
|
||||||
// is_hidden INTEGER,
|
|
||||||
// summary TEXT,
|
|
||||||
// draft TEXT,
|
|
||||||
// status INTEGER,
|
|
||||||
// last_timestamp INTEGER,
|
|
||||||
// sort_timestamp INTEGER,
|
|
||||||
// last_clear_unread_timestamp INTEGER,
|
|
||||||
// last_msg_locald_id INTEGER,
|
|
||||||
// last_msg_type INTEGER,
|
|
||||||
// last_msg_sub_type INTEGER,
|
|
||||||
// last_msg_sender TEXT,
|
|
||||||
// last_sender_display_name TEXT,
|
|
||||||
// last_msg_ext_type INTEGER
|
|
||||||
// )
|
|
||||||
type SessionV4 struct {
|
|
||||||
Username string `json:"username"`
|
|
||||||
Summary string `json:"summary"`
|
|
||||||
LastTimestamp int `json:"last_timestamp"`
|
|
||||||
LastMsgSender string `json:"last_msg_sender"`
|
|
||||||
LastSenderDisplayName string `json:"last_sender_display_name"`
|
|
||||||
|
|
||||||
// Type int `json:"type"`
|
|
||||||
// UnreadCount int `json:"unread_count"`
|
|
||||||
// UnreadFirstMsgSrvID int `json:"unread_first_msg_srv_id"`
|
|
||||||
// IsHidden int `json:"is_hidden"`
|
|
||||||
// Draft string `json:"draft"`
|
|
||||||
// Status int `json:"status"`
|
|
||||||
// SortTimestamp int `json:"sort_timestamp"`
|
|
||||||
// LastClearUnreadTimestamp int `json:"last_clear_unread_timestamp"`
|
|
||||||
// LastMsgLocaldID int `json:"last_msg_locald_id"`
|
|
||||||
// LastMsgType int `json:"last_msg_type"`
|
|
||||||
// LastMsgSubType int `json:"last_msg_sub_type"`
|
|
||||||
// LastMsgExtType int `json:"last_msg_ext_type"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Session) PlainText(limit int) string {
|
func (s *Session) PlainText(limit int) string {
|
||||||
buf := strings.Builder{}
|
buf := strings.Builder{}
|
||||||
buf.WriteString(s.NickName)
|
buf.WriteString(s.NickName)
|
||||||
41
internal/model/session_darwinv3.go
Normal file
41
internal/model/session_darwinv3.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// CREATE TABLE SessionAbstract(
|
||||||
|
// m_nsUserName TEXT PRIMARY KEY,
|
||||||
|
// m_uUnReadCount INTEGER,
|
||||||
|
// m_bShowUnReadAsRedDot INTEGER,
|
||||||
|
// m_bMarkUnread INTEGER,
|
||||||
|
// m_uLastTime INTEGER,
|
||||||
|
// strRes1 TEXT,
|
||||||
|
// strRes2 TEXT,
|
||||||
|
// strRes3 TEXT,
|
||||||
|
// intRes1 INTEGER,
|
||||||
|
// intRes2 INTEGER,
|
||||||
|
// intRes3 INTEGER,
|
||||||
|
// _packed_MMSessionInfo BLOB
|
||||||
|
// )
|
||||||
|
type SessionDarwinV3 struct {
|
||||||
|
M_nsUserName string `json:"m_nsUserName"`
|
||||||
|
M_uLastTime int `json:"m_uLastTime"`
|
||||||
|
|
||||||
|
// M_uUnReadCount int `json:"m_uUnReadCount"`
|
||||||
|
// M_bShowUnReadAsRedDot int `json:"m_bShowUnReadAsRedDot"`
|
||||||
|
// M_bMarkUnread int `json:"m_bMarkUnread"`
|
||||||
|
// StrRes1 string `json:"strRes1"`
|
||||||
|
// StrRes2 string `json:"strRes2"`
|
||||||
|
// StrRes3 string `json:"strRes3"`
|
||||||
|
// IntRes1 int `json:"intRes1"`
|
||||||
|
// IntRes2 int `json:"intRes2"`
|
||||||
|
// IntRes3 int `json:"intRes3"`
|
||||||
|
// PackedMMSessionInfo string `json:"_packed_MMSessionInfo"` // TODO: decode
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionDarwinV3) Wrap() *Session {
|
||||||
|
return &Session{
|
||||||
|
UserName: s.M_nsUserName,
|
||||||
|
NOrder: s.M_uLastTime,
|
||||||
|
NTime: time.Unix(int64(s.M_uLastTime), 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
54
internal/model/session_v4.go
Normal file
54
internal/model/session_v4.go
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
import "time"
|
||||||
|
|
||||||
|
// 注意,v4 session 是独立数据库文件
|
||||||
|
// CREATE TABLE SessionTable(
|
||||||
|
// username TEXT PRIMARY KEY,
|
||||||
|
// type INTEGER,
|
||||||
|
// unread_count INTEGER,
|
||||||
|
// unread_first_msg_srv_id INTEGER,
|
||||||
|
// is_hidden INTEGER,
|
||||||
|
// summary TEXT,
|
||||||
|
// draft TEXT,
|
||||||
|
// status INTEGER,
|
||||||
|
// last_timestamp INTEGER,
|
||||||
|
// sort_timestamp INTEGER,
|
||||||
|
// last_clear_unread_timestamp INTEGER,
|
||||||
|
// last_msg_locald_id INTEGER,
|
||||||
|
// last_msg_type INTEGER,
|
||||||
|
// last_msg_sub_type INTEGER,
|
||||||
|
// last_msg_sender TEXT,
|
||||||
|
// last_sender_display_name TEXT,
|
||||||
|
// last_msg_ext_type INTEGER
|
||||||
|
// )
|
||||||
|
type SessionV4 struct {
|
||||||
|
Username string `json:"username"`
|
||||||
|
Summary string `json:"summary"`
|
||||||
|
LastTimestamp int `json:"last_timestamp"`
|
||||||
|
LastMsgSender string `json:"last_msg_sender"`
|
||||||
|
LastSenderDisplayName string `json:"last_sender_display_name"`
|
||||||
|
|
||||||
|
// Type int `json:"type"`
|
||||||
|
// UnreadCount int `json:"unread_count"`
|
||||||
|
// UnreadFirstMsgSrvID int `json:"unread_first_msg_srv_id"`
|
||||||
|
// IsHidden int `json:"is_hidden"`
|
||||||
|
// Draft string `json:"draft"`
|
||||||
|
// Status int `json:"status"`
|
||||||
|
// SortTimestamp int `json:"sort_timestamp"`
|
||||||
|
// LastClearUnreadTimestamp int `json:"last_clear_unread_timestamp"`
|
||||||
|
// LastMsgLocaldID int `json:"last_msg_locald_id"`
|
||||||
|
// LastMsgType int `json:"last_msg_type"`
|
||||||
|
// LastMsgSubType int `json:"last_msg_sub_type"`
|
||||||
|
// LastMsgExtType int `json:"last_msg_ext_type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *SessionV4) Wrap() *Session {
|
||||||
|
return &Session{
|
||||||
|
UserName: s.Username,
|
||||||
|
NOrder: s.LastTimestamp,
|
||||||
|
NickName: s.LastSenderDisplayName,
|
||||||
|
Content: s.Summary,
|
||||||
|
NTime: time.Unix(int64(s.LastTimestamp), 0),
|
||||||
|
}
|
||||||
|
}
|
||||||
252
internal/model/wxproto/packedinfo.pb.go
Normal file
252
internal/model/wxproto/packedinfo.pb.go
Normal file
@@ -0,0 +1,252 @@
|
|||||||
|
// Code generated by protoc-gen-go. DO NOT EDIT.
|
||||||
|
// versions:
|
||||||
|
// protoc-gen-go v1.36.5
|
||||||
|
// protoc v5.29.3
|
||||||
|
// source: packedinfo.proto
|
||||||
|
|
||||||
|
package wxproto
|
||||||
|
|
||||||
|
import (
|
||||||
|
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
|
||||||
|
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
|
||||||
|
reflect "reflect"
|
||||||
|
sync "sync"
|
||||||
|
unsafe "unsafe"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// Verify that this generated code is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
|
||||||
|
// Verify that runtime/protoimpl is sufficiently up-to-date.
|
||||||
|
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
|
||||||
|
)
|
||||||
|
|
||||||
|
type PackedInfo struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Type uint32 `protobuf:"varint,1,opt,name=type,proto3" json:"type,omitempty"` // 始终为 106 (0x6a)
|
||||||
|
Version uint32 `protobuf:"varint,2,opt,name=version,proto3" json:"version,omitempty"` // 始终为 14 (0xe)
|
||||||
|
Image *ImageHash `protobuf:"bytes,3,opt,name=image,proto3" json:"image,omitempty"` // 图片哈希
|
||||||
|
Video *VideoHash `protobuf:"bytes,4,opt,name=video,proto3" json:"video,omitempty"` // 视频哈希
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PackedInfo) Reset() {
|
||||||
|
*x = PackedInfo{}
|
||||||
|
mi := &file_packedinfo_proto_msgTypes[0]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PackedInfo) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*PackedInfo) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *PackedInfo) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_packedinfo_proto_msgTypes[0]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use PackedInfo.ProtoReflect.Descriptor instead.
|
||||||
|
func (*PackedInfo) Descriptor() ([]byte, []int) {
|
||||||
|
return file_packedinfo_proto_rawDescGZIP(), []int{0}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PackedInfo) GetType() uint32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Type
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PackedInfo) GetVersion() uint32 {
|
||||||
|
if x != nil {
|
||||||
|
return x.Version
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PackedInfo) GetImage() *ImageHash {
|
||||||
|
if x != nil {
|
||||||
|
return x.Image
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *PackedInfo) GetVideo() *VideoHash {
|
||||||
|
if x != nil {
|
||||||
|
return x.Video
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
type ImageHash struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Md5 string `protobuf:"bytes,4,opt,name=md5,proto3" json:"md5,omitempty"` // 32 字符的 MD5 哈希
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ImageHash) Reset() {
|
||||||
|
*x = ImageHash{}
|
||||||
|
mi := &file_packedinfo_proto_msgTypes[1]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ImageHash) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*ImageHash) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *ImageHash) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_packedinfo_proto_msgTypes[1]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use ImageHash.ProtoReflect.Descriptor instead.
|
||||||
|
func (*ImageHash) Descriptor() ([]byte, []int) {
|
||||||
|
return file_packedinfo_proto_rawDescGZIP(), []int{1}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *ImageHash) GetMd5() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Md5
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
type VideoHash struct {
|
||||||
|
state protoimpl.MessageState `protogen:"open.v1"`
|
||||||
|
Md5 string `protobuf:"bytes,8,opt,name=md5,proto3" json:"md5,omitempty"` // 32 字符的 MD5 哈希
|
||||||
|
unknownFields protoimpl.UnknownFields
|
||||||
|
sizeCache protoimpl.SizeCache
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VideoHash) Reset() {
|
||||||
|
*x = VideoHash{}
|
||||||
|
mi := &file_packedinfo_proto_msgTypes[2]
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VideoHash) String() string {
|
||||||
|
return protoimpl.X.MessageStringOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (*VideoHash) ProtoMessage() {}
|
||||||
|
|
||||||
|
func (x *VideoHash) ProtoReflect() protoreflect.Message {
|
||||||
|
mi := &file_packedinfo_proto_msgTypes[2]
|
||||||
|
if x != nil {
|
||||||
|
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
|
||||||
|
if ms.LoadMessageInfo() == nil {
|
||||||
|
ms.StoreMessageInfo(mi)
|
||||||
|
}
|
||||||
|
return ms
|
||||||
|
}
|
||||||
|
return mi.MessageOf(x)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Deprecated: Use VideoHash.ProtoReflect.Descriptor instead.
|
||||||
|
func (*VideoHash) Descriptor() ([]byte, []int) {
|
||||||
|
return file_packedinfo_proto_rawDescGZIP(), []int{2}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (x *VideoHash) GetMd5() string {
|
||||||
|
if x != nil {
|
||||||
|
return x.Md5
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
var File_packedinfo_proto protoreflect.FileDescriptor
|
||||||
|
|
||||||
|
var file_packedinfo_proto_rawDesc = string([]byte{
|
||||||
|
0x0a, 0x10, 0x70, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x69, 0x6e, 0x66, 0x6f, 0x2e, 0x70, 0x72, 0x6f,
|
||||||
|
0x74, 0x6f, 0x12, 0x0c, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
|
||||||
|
0x22, 0x98, 0x01, 0x0a, 0x0a, 0x50, 0x61, 0x63, 0x6b, 0x65, 0x64, 0x49, 0x6e, 0x66, 0x6f, 0x12,
|
||||||
|
0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0d, 0x52, 0x04, 0x74,
|
||||||
|
0x79, 0x70, 0x65, 0x12, 0x18, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x02,
|
||||||
|
0x20, 0x01, 0x28, 0x0d, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, 0x2d, 0x0a,
|
||||||
|
0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61,
|
||||||
|
0x70, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x49, 0x6d, 0x61, 0x67,
|
||||||
|
0x65, 0x48, 0x61, 0x73, 0x68, 0x52, 0x05, 0x69, 0x6d, 0x61, 0x67, 0x65, 0x12, 0x2d, 0x0a, 0x05,
|
||||||
|
0x76, 0x69, 0x64, 0x65, 0x6f, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x61, 0x70,
|
||||||
|
0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x56, 0x69, 0x64, 0x65, 0x6f,
|
||||||
|
0x48, 0x61, 0x73, 0x68, 0x52, 0x05, 0x76, 0x69, 0x64, 0x65, 0x6f, 0x22, 0x1d, 0x0a, 0x09, 0x49,
|
||||||
|
0x6d, 0x61, 0x67, 0x65, 0x48, 0x61, 0x73, 0x68, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x64, 0x35, 0x18,
|
||||||
|
0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x64, 0x35, 0x22, 0x1d, 0x0a, 0x09, 0x56, 0x69,
|
||||||
|
0x64, 0x65, 0x6f, 0x48, 0x61, 0x73, 0x68, 0x12, 0x10, 0x0a, 0x03, 0x6d, 0x64, 0x35, 0x18, 0x08,
|
||||||
|
0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6d, 0x64, 0x35, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x3b, 0x77,
|
||||||
|
0x78, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
|
||||||
|
})
|
||||||
|
|
||||||
|
var (
|
||||||
|
file_packedinfo_proto_rawDescOnce sync.Once
|
||||||
|
file_packedinfo_proto_rawDescData []byte
|
||||||
|
)
|
||||||
|
|
||||||
|
func file_packedinfo_proto_rawDescGZIP() []byte {
|
||||||
|
file_packedinfo_proto_rawDescOnce.Do(func() {
|
||||||
|
file_packedinfo_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_packedinfo_proto_rawDesc), len(file_packedinfo_proto_rawDesc)))
|
||||||
|
})
|
||||||
|
return file_packedinfo_proto_rawDescData
|
||||||
|
}
|
||||||
|
|
||||||
|
var file_packedinfo_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
|
||||||
|
var file_packedinfo_proto_goTypes = []any{
|
||||||
|
(*PackedInfo)(nil), // 0: app.protobuf.PackedInfo
|
||||||
|
(*ImageHash)(nil), // 1: app.protobuf.ImageHash
|
||||||
|
(*VideoHash)(nil), // 2: app.protobuf.VideoHash
|
||||||
|
}
|
||||||
|
var file_packedinfo_proto_depIdxs = []int32{
|
||||||
|
1, // 0: app.protobuf.PackedInfo.image:type_name -> app.protobuf.ImageHash
|
||||||
|
2, // 1: app.protobuf.PackedInfo.video:type_name -> app.protobuf.VideoHash
|
||||||
|
2, // [2:2] is the sub-list for method output_type
|
||||||
|
2, // [2:2] is the sub-list for method input_type
|
||||||
|
2, // [2:2] is the sub-list for extension type_name
|
||||||
|
2, // [2:2] is the sub-list for extension extendee
|
||||||
|
0, // [0:2] is the sub-list for field type_name
|
||||||
|
}
|
||||||
|
|
||||||
|
func init() { file_packedinfo_proto_init() }
|
||||||
|
func file_packedinfo_proto_init() {
|
||||||
|
if File_packedinfo_proto != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type x struct{}
|
||||||
|
out := protoimpl.TypeBuilder{
|
||||||
|
File: protoimpl.DescBuilder{
|
||||||
|
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
|
||||||
|
RawDescriptor: unsafe.Slice(unsafe.StringData(file_packedinfo_proto_rawDesc), len(file_packedinfo_proto_rawDesc)),
|
||||||
|
NumEnums: 0,
|
||||||
|
NumMessages: 3,
|
||||||
|
NumExtensions: 0,
|
||||||
|
NumServices: 0,
|
||||||
|
},
|
||||||
|
GoTypes: file_packedinfo_proto_goTypes,
|
||||||
|
DependencyIndexes: file_packedinfo_proto_depIdxs,
|
||||||
|
MessageInfos: file_packedinfo_proto_msgTypes,
|
||||||
|
}.Build()
|
||||||
|
File_packedinfo_proto = out.File
|
||||||
|
file_packedinfo_proto_goTypes = nil
|
||||||
|
file_packedinfo_proto_depIdxs = nil
|
||||||
|
}
|
||||||
19
internal/model/wxproto/packedinfo.proto
Normal file
19
internal/model/wxproto/packedinfo.proto
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
syntax = "proto3";
|
||||||
|
package app.protobuf;
|
||||||
|
option go_package=".;wxproto";
|
||||||
|
|
||||||
|
message PackedInfo {
|
||||||
|
uint32 type = 1; // 始终为 106 (0x6a)
|
||||||
|
uint32 version = 2; // 始终为 14 (0xe)
|
||||||
|
ImageHash image = 3; // 图片哈希
|
||||||
|
VideoHash video = 4; // 视频哈希
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
message ImageHash {
|
||||||
|
string md5 = 4; // 32 字符的 MD5 哈希
|
||||||
|
}
|
||||||
|
|
||||||
|
message VideoHash {
|
||||||
|
string md5 = 8; // 32 字符的 MD5 哈希
|
||||||
|
}
|
||||||
@@ -34,7 +34,7 @@ func New() *Footer {
|
|||||||
SetTextAlign(tview.AlignLeft)
|
SetTextAlign(tview.AlignLeft)
|
||||||
footer.copyRight.
|
footer.copyRight.
|
||||||
SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
|
SetBackgroundColor(tview.Styles.PrimitiveBackgroundColor)
|
||||||
footer.copyRight.SetText(fmt.Sprintf("[%s::b]%s[-:-:-]", style.GetColorHex(style.PageHeaderFgColor), fmt.Sprintf(" @ Sarv's Chatlog (%s)", version.Version)))
|
footer.copyRight.SetText(fmt.Sprintf("[%s::b]%s[-:-:-]", style.GetColorHex(style.PageHeaderFgColor), fmt.Sprintf(" @ Sarv's Chatlog %s", version.Version)))
|
||||||
|
|
||||||
footer.help.
|
footer.help.
|
||||||
SetDynamicColors(true).
|
SetDynamicColors(true).
|
||||||
|
|||||||
258
internal/ui/form/form.go
Normal file
258
internal/ui/form/form.go
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
package form
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
|
||||||
|
"github.com/gdamore/tcell/v2"
|
||||||
|
"github.com/rivo/tview"
|
||||||
|
"github.com/sjzar/chatlog/internal/ui/style"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
// DialogPadding dialog inner padding.
|
||||||
|
DialogPadding = 3
|
||||||
|
|
||||||
|
// DialogHelpHeight dialog help text height.
|
||||||
|
DialogHelpHeight = 1
|
||||||
|
|
||||||
|
// DialogMinWidth dialog min width.
|
||||||
|
DialogMinWidth = 40
|
||||||
|
|
||||||
|
// FormHeightOffset form height offset for border.
|
||||||
|
FormHeightOffset = 3
|
||||||
|
|
||||||
|
// 额外的宽度补偿,类似于 submenu 的 cmdWidthOffset
|
||||||
|
formWidthOffset = 10
|
||||||
|
)
|
||||||
|
|
||||||
|
// Form is a modal form component with a title, form fields, and help text.
|
||||||
|
type Form struct {
|
||||||
|
*tview.Box
|
||||||
|
title string
|
||||||
|
layout *tview.Flex
|
||||||
|
form *tview.Form
|
||||||
|
helpText *tview.TextView
|
||||||
|
width int
|
||||||
|
height int
|
||||||
|
cancelHandler func()
|
||||||
|
fields []formField // 存储字段信息以便重新计算宽度
|
||||||
|
}
|
||||||
|
|
||||||
|
// formField 存储表单字段的信息
|
||||||
|
type formField struct {
|
||||||
|
label string
|
||||||
|
value string
|
||||||
|
fieldWidth int
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewForm creates a new form with the given title.
|
||||||
|
func NewForm(title string) *Form {
|
||||||
|
f := &Form{
|
||||||
|
Box: tview.NewBox(),
|
||||||
|
title: title,
|
||||||
|
layout: tview.NewFlex().SetDirection(tview.FlexRow),
|
||||||
|
form: tview.NewForm(),
|
||||||
|
fields: make([]formField, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置表单样式
|
||||||
|
f.form.SetBorderPadding(1, 1, 1, 1)
|
||||||
|
f.form.SetBackgroundColor(style.DialogBgColor)
|
||||||
|
f.form.SetFieldBackgroundColor(style.BgColor)
|
||||||
|
f.form.SetFieldTextColor(style.FgColor)
|
||||||
|
f.form.SetButtonBackgroundColor(style.ButtonBgColor)
|
||||||
|
f.form.SetButtonTextColor(style.FgColor)
|
||||||
|
f.form.SetLabelColor(style.DialogFgColor)
|
||||||
|
f.form.SetButtonsAlign(tview.AlignCenter)
|
||||||
|
|
||||||
|
// 创建帮助文本
|
||||||
|
f.helpText = tview.NewTextView()
|
||||||
|
f.helpText.SetDynamicColors(true)
|
||||||
|
f.helpText.SetTextAlign(tview.AlignCenter)
|
||||||
|
f.helpText.SetTextColor(style.DialogFgColor)
|
||||||
|
f.helpText.SetBackgroundColor(style.DialogBgColor)
|
||||||
|
fmt.Fprintf(f.helpText,
|
||||||
|
"[%s::b]Tab[%s::b]: 导航 [%s::b]Enter[%s::b]: 选择 [%s::b]ESC[%s::b]: 返回",
|
||||||
|
style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
|
||||||
|
style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
|
||||||
|
style.GetColorHex(style.MenuBgColor), style.GetColorHex(style.PageHeaderFgColor),
|
||||||
|
)
|
||||||
|
|
||||||
|
// 创建布局
|
||||||
|
formLayout := tview.NewFlex().SetDirection(tview.FlexColumn)
|
||||||
|
formLayout.AddItem(EmptyBoxSpace(style.DialogBgColor), 1, 0, false)
|
||||||
|
formLayout.AddItem(f.form, 0, 1, true)
|
||||||
|
formLayout.AddItem(EmptyBoxSpace(style.DialogBgColor), 1, 0, false)
|
||||||
|
|
||||||
|
// 设置主布局
|
||||||
|
f.layout.SetTitle(fmt.Sprintf("[::b]%s", f.title))
|
||||||
|
f.layout.SetTitleColor(style.DialogFgColor)
|
||||||
|
f.layout.SetTitleAlign(tview.AlignCenter)
|
||||||
|
f.layout.SetBorder(true)
|
||||||
|
f.layout.SetBorderColor(style.DialogBorderColor)
|
||||||
|
f.layout.SetBackgroundColor(style.DialogBgColor)
|
||||||
|
|
||||||
|
// 添加表单区域
|
||||||
|
f.layout.AddItem(formLayout, 0, 1, true)
|
||||||
|
|
||||||
|
// 添加帮助文本区域
|
||||||
|
f.layout.AddItem(f.helpText, DialogHelpHeight, 0, false)
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddInputField adds an input field to the form.
|
||||||
|
func (f *Form) AddInputField(label, value string, fieldWidth int, accept func(textToCheck string, lastChar rune) bool, changed func(text string)) *Form {
|
||||||
|
// 存储字段信息
|
||||||
|
f.fields = append(f.fields, formField{
|
||||||
|
label: label,
|
||||||
|
value: value,
|
||||||
|
fieldWidth: fieldWidth,
|
||||||
|
})
|
||||||
|
|
||||||
|
// 添加输入字段到表单
|
||||||
|
f.form.AddInputField(label, value, fieldWidth, accept, changed)
|
||||||
|
|
||||||
|
// 更新表单尺寸
|
||||||
|
f.recalculateSize()
|
||||||
|
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddButton adds a button to the form.
|
||||||
|
func (f *Form) AddButton(label string, selected func()) *Form {
|
||||||
|
f.form.AddButton(label, selected)
|
||||||
|
// 更新表单尺寸
|
||||||
|
f.recalculateSize()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddCheckbox adds a checkbox to the form.
|
||||||
|
func (f *Form) AddCheckbox(label string, checked bool, changed func(checked bool)) *Form {
|
||||||
|
f.form.AddCheckbox(label, checked, changed)
|
||||||
|
// 更新表单尺寸
|
||||||
|
f.recalculateSize()
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetCancelFunc sets the function to be called when the form is cancelled.
|
||||||
|
func (f *Form) SetCancelFunc(handler func()) *Form {
|
||||||
|
f.cancelHandler = handler
|
||||||
|
return f
|
||||||
|
}
|
||||||
|
|
||||||
|
// recalculateSize 重新计算表单尺寸
|
||||||
|
func (f *Form) recalculateSize() {
|
||||||
|
// 计算表单项数量
|
||||||
|
itemCount := f.form.GetFormItemCount()
|
||||||
|
|
||||||
|
// 计算高度 - 每个表单项占2行,按钮区域至少占2行,再加上边框和帮助文本
|
||||||
|
f.height = (itemCount * 2) + 2 + FormHeightOffset + DialogHelpHeight
|
||||||
|
|
||||||
|
// 计算宽度 - 类似于 submenu 的实现
|
||||||
|
maxLabelWidth := 0
|
||||||
|
maxValueWidth := 0
|
||||||
|
|
||||||
|
// 遍历所有字段,找出最长的标签和值
|
||||||
|
for _, field := range f.fields {
|
||||||
|
if len(field.label) > maxLabelWidth {
|
||||||
|
maxLabelWidth = len(field.label)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对于值,使用字段宽度和实际值长度中的较大者
|
||||||
|
valueWidth := field.fieldWidth
|
||||||
|
if len(field.value) > valueWidth {
|
||||||
|
valueWidth = len(field.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
if valueWidth > maxValueWidth {
|
||||||
|
maxValueWidth = valueWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算总宽度,类似于 submenu 的计算方式
|
||||||
|
f.width = maxLabelWidth + maxValueWidth + formWidthOffset
|
||||||
|
|
||||||
|
// 确保宽度不小于最小值
|
||||||
|
if f.width < DialogMinWidth {
|
||||||
|
f.width = DialogMinWidth
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw draws the form on the screen.
|
||||||
|
func (f *Form) Draw(screen tcell.Screen) {
|
||||||
|
// 在绘制前重新计算尺寸,确保尺寸是最新的
|
||||||
|
f.recalculateSize()
|
||||||
|
|
||||||
|
// 绘制
|
||||||
|
f.Box.DrawForSubclass(screen, f)
|
||||||
|
f.layout.Draw(screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetRect sets the position and size of the form.
|
||||||
|
func (f *Form) SetRect(x, y, width, height int) {
|
||||||
|
// 确保尺寸是最新的
|
||||||
|
f.recalculateSize()
|
||||||
|
|
||||||
|
// 类似于 submenu 的实现
|
||||||
|
ws := (width - f.width) / 2
|
||||||
|
hs := (height - f.height) / 2
|
||||||
|
|
||||||
|
// 确保不会超出屏幕
|
||||||
|
if f.width > width {
|
||||||
|
ws = 0
|
||||||
|
f.width = width - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
if f.height > height {
|
||||||
|
hs = 0
|
||||||
|
f.height = height - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
// 设置表单位置
|
||||||
|
f.Box.SetRect(x+ws, y+hs, f.width, f.height)
|
||||||
|
|
||||||
|
// 获取内部矩形并设置布局
|
||||||
|
x, y, width, height = f.Box.GetInnerRect()
|
||||||
|
f.layout.SetRect(x, y, width, height)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus is called when this primitive receives focus.
|
||||||
|
func (f *Form) Focus(delegate func(p tview.Primitive)) {
|
||||||
|
// 确保表单获得焦点
|
||||||
|
if f.form != nil {
|
||||||
|
delegate(f.form)
|
||||||
|
} else {
|
||||||
|
// 如果表单为空,则让Box获得焦点
|
||||||
|
delegate(f.Box)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// HasFocus returns whether or not this primitive has focus.
|
||||||
|
func (f *Form) HasFocus() bool {
|
||||||
|
return f.form.HasFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
// InputHandler returns the handler for this primitive.
|
||||||
|
func (f *Form) InputHandler() func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||||
|
return f.WrapInputHandler(func(event *tcell.EventKey, setFocus func(p tview.Primitive)) {
|
||||||
|
// ESC键处理
|
||||||
|
if event.Key() == tcell.KeyEscape && f.cancelHandler != nil {
|
||||||
|
f.cancelHandler()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将事件传递给表单
|
||||||
|
if handler := f.form.InputHandler(); handler != nil {
|
||||||
|
handler(event, setFocus)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// EmptyBoxSpace creates an empty box with the specified background color.
|
||||||
|
func EmptyBoxSpace(bgColor tcell.Color) *tview.Box {
|
||||||
|
box := tview.NewBox()
|
||||||
|
box.SetBackgroundColor(bgColor)
|
||||||
|
box.SetBorder(false)
|
||||||
|
return box
|
||||||
|
}
|
||||||
@@ -22,20 +22,22 @@ const (
|
|||||||
|
|
||||||
[green]使用步骤:[white]
|
[green]使用步骤:[white]
|
||||||
|
|
||||||
[yellow]1. 获取数据密钥[white]
|
[yellow]1. 下载并安装微信客户端[white]
|
||||||
选择"获取数据密钥"菜单项,程序会自动从运行中的微信进程获取密钥。
|
|
||||||
如果有多个微信进程,会自动选择当前账号的进程。
|
|
||||||
确保微信正在运行,否则无法获取密钥。
|
|
||||||
|
|
||||||
[yellow]2. 解密数据[white]
|
[yellow]2. 迁移手机微信聊天记录[white]
|
||||||
选择"解密数据"菜单项,程序会使用获取的密钥解密微信数据库文件。
|
手机微信上操作 [yellow]我 - 设置 - 通用 - 聊天记录迁移与备份 - 迁移 - 迁移到电脑[white]。
|
||||||
|
这一步的目的是将手机中的聊天记录传输到电脑上。
|
||||||
|
可以放心操作,不会影响到手机上的聊天记录。
|
||||||
|
|
||||||
|
[yellow]3. 解密数据[white]
|
||||||
|
重新打开 chatlog,选择"解密数据"菜单项,程序会使用获取的密钥解密微信数据库文件。
|
||||||
解密后的文件会保存到工作目录中(可在设置中修改)。
|
解密后的文件会保存到工作目录中(可在设置中修改)。
|
||||||
|
|
||||||
[yellow]3. 启动 HTTP 服务[white]
|
[yellow]4. 启动 HTTP 服务[white]
|
||||||
选择"启动 HTTP 服务"菜单项,启动 HTTP 和 MCP 服务。
|
选择"启动 HTTP 服务"菜单项,启动 HTTP 和 MCP 服务。
|
||||||
启动后可以通过浏览器访问 http://localhost:5030 查看聊天记录。
|
启动后可以通过浏览器访问 http://localhost:5030 查看聊天记录。
|
||||||
|
|
||||||
[yellow]4. 设置选项[white]
|
[yellow]5. 设置选项[white]
|
||||||
选择"设置"菜单项,可以配置:
|
选择"设置"菜单项,可以配置:
|
||||||
• HTTP 服务端口 - 更改 HTTP 服务的监听端口
|
• HTTP 服务端口 - 更改 HTTP 服务的监听端口
|
||||||
• 工作目录 - 更改解密数据的存储位置
|
• 工作目录 - 更改解密数据的存储位置
|
||||||
|
|||||||
@@ -15,13 +15,14 @@ const (
|
|||||||
|
|
||||||
// InfoBarViewHeight info bar height.
|
// InfoBarViewHeight info bar height.
|
||||||
const (
|
const (
|
||||||
InfoBarViewHeight = 6
|
InfoBarViewHeight = 7
|
||||||
accountRow = 0
|
accountRow = 0
|
||||||
pidRow = 1
|
statusRow = 1
|
||||||
statusRow = 2
|
platformRow = 2
|
||||||
dataUsageRow = 3
|
sessionRow = 3
|
||||||
workUsageRow = 4
|
dataUsageRow = 4
|
||||||
httpServerRow = 5
|
workUsageRow = 5
|
||||||
|
httpServerRow = 6
|
||||||
|
|
||||||
// 列索引
|
// 列索引
|
||||||
labelCol1 = 0 // 第一列标签
|
labelCol1 = 0 // 第一列标签
|
||||||
@@ -43,7 +44,7 @@ func New() *InfoBar {
|
|||||||
table := tview.NewTable()
|
table := tview.NewTable()
|
||||||
headerColor := style.InfoBarItemFgColor
|
headerColor := style.InfoBarItemFgColor
|
||||||
|
|
||||||
// Account 和 Version 行
|
// Account 和 PID 行
|
||||||
table.SetCell(
|
table.SetCell(
|
||||||
accountRow,
|
accountRow,
|
||||||
labelCol1,
|
labelCol1,
|
||||||
@@ -54,26 +55,11 @@ func New() *InfoBar {
|
|||||||
table.SetCell(
|
table.SetCell(
|
||||||
accountRow,
|
accountRow,
|
||||||
labelCol2,
|
labelCol2,
|
||||||
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Version:")),
|
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "PID:")),
|
||||||
)
|
)
|
||||||
table.SetCell(accountRow, valueCol2, tview.NewTableCell(""))
|
table.SetCell(accountRow, valueCol2, tview.NewTableCell(""))
|
||||||
|
|
||||||
// PID 和 ExePath 行
|
// Status 和 ExePath 行
|
||||||
table.SetCell(
|
|
||||||
pidRow,
|
|
||||||
labelCol1,
|
|
||||||
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "PID:")),
|
|
||||||
)
|
|
||||||
table.SetCell(pidRow, valueCol1, tview.NewTableCell(""))
|
|
||||||
|
|
||||||
table.SetCell(
|
|
||||||
pidRow,
|
|
||||||
labelCol2,
|
|
||||||
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "ExePath:")),
|
|
||||||
)
|
|
||||||
table.SetCell(pidRow, valueCol2, tview.NewTableCell(""))
|
|
||||||
|
|
||||||
// Status 和 Key 行
|
|
||||||
table.SetCell(
|
table.SetCell(
|
||||||
statusRow,
|
statusRow,
|
||||||
labelCol1,
|
labelCol1,
|
||||||
@@ -84,10 +70,40 @@ func New() *InfoBar {
|
|||||||
table.SetCell(
|
table.SetCell(
|
||||||
statusRow,
|
statusRow,
|
||||||
labelCol2,
|
labelCol2,
|
||||||
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Data Key:")),
|
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "ExePath:")),
|
||||||
)
|
)
|
||||||
table.SetCell(statusRow, valueCol2, tview.NewTableCell(""))
|
table.SetCell(statusRow, valueCol2, tview.NewTableCell(""))
|
||||||
|
|
||||||
|
// Platform 和 Version 行
|
||||||
|
table.SetCell(
|
||||||
|
platformRow,
|
||||||
|
labelCol1,
|
||||||
|
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Platform:")),
|
||||||
|
)
|
||||||
|
table.SetCell(platformRow, valueCol1, tview.NewTableCell(""))
|
||||||
|
|
||||||
|
table.SetCell(
|
||||||
|
platformRow,
|
||||||
|
labelCol2,
|
||||||
|
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Version:")),
|
||||||
|
)
|
||||||
|
table.SetCell(platformRow, valueCol2, tview.NewTableCell(""))
|
||||||
|
|
||||||
|
// Session 和 Data Key 行
|
||||||
|
table.SetCell(
|
||||||
|
sessionRow,
|
||||||
|
labelCol1,
|
||||||
|
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Session:")),
|
||||||
|
)
|
||||||
|
table.SetCell(sessionRow, valueCol1, tview.NewTableCell(""))
|
||||||
|
|
||||||
|
table.SetCell(
|
||||||
|
sessionRow,
|
||||||
|
labelCol2,
|
||||||
|
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Data Key:")),
|
||||||
|
)
|
||||||
|
table.SetCell(sessionRow, valueCol2, tview.NewTableCell(""))
|
||||||
|
|
||||||
// Data Usage 和 Data Dir 行
|
// Data Usage 和 Data Dir 行
|
||||||
table.SetCell(
|
table.SetCell(
|
||||||
dataUsageRow,
|
dataUsageRow,
|
||||||
@@ -126,6 +142,13 @@ func New() *InfoBar {
|
|||||||
)
|
)
|
||||||
table.SetCell(httpServerRow, valueCol1, tview.NewTableCell(""))
|
table.SetCell(httpServerRow, valueCol1, tview.NewTableCell(""))
|
||||||
|
|
||||||
|
table.SetCell(
|
||||||
|
httpServerRow,
|
||||||
|
labelCol2,
|
||||||
|
tview.NewTableCell(fmt.Sprintf(" [%s::]%s", headerColor, "Auto Decrypt:")),
|
||||||
|
)
|
||||||
|
table.SetCell(httpServerRow, valueCol2, tview.NewTableCell(""))
|
||||||
|
|
||||||
// infobar
|
// infobar
|
||||||
infoBar := &InfoBar{
|
infoBar := &InfoBar{
|
||||||
Box: tview.NewBox(),
|
Box: tview.NewBox(),
|
||||||
@@ -141,17 +164,25 @@ func (info *InfoBar) UpdateAccount(account string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (info *InfoBar) UpdateBasicInfo(pid int, version string, exePath string) {
|
func (info *InfoBar) UpdateBasicInfo(pid int, version string, exePath string) {
|
||||||
info.table.GetCell(pidRow, valueCol1).SetText(fmt.Sprintf("%d", pid))
|
info.table.GetCell(accountRow, valueCol2).SetText(fmt.Sprintf("%d", pid))
|
||||||
info.table.GetCell(pidRow, valueCol2).SetText(exePath)
|
info.table.GetCell(statusRow, valueCol2).SetText(exePath)
|
||||||
info.table.GetCell(accountRow, valueCol2).SetText(version)
|
info.table.GetCell(platformRow, valueCol2).SetText(version)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (info *InfoBar) UpdateStatus(status string) {
|
func (info *InfoBar) UpdateStatus(status string) {
|
||||||
info.table.GetCell(statusRow, valueCol1).SetText(status)
|
info.table.GetCell(statusRow, valueCol1).SetText(status)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (info *InfoBar) UpdatePlatform(text string) {
|
||||||
|
info.table.GetCell(platformRow, valueCol1).SetText(text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (info *InfoBar) UpdateSession(text string) {
|
||||||
|
info.table.GetCell(sessionRow, valueCol1).SetText(text)
|
||||||
|
}
|
||||||
|
|
||||||
func (info *InfoBar) UpdateDataKey(key string) {
|
func (info *InfoBar) UpdateDataKey(key string) {
|
||||||
info.table.GetCell(statusRow, valueCol2).SetText(key)
|
info.table.GetCell(sessionRow, valueCol2).SetText(key)
|
||||||
}
|
}
|
||||||
|
|
||||||
func (info *InfoBar) UpdateDataUsageDir(dataUsage string, dataDir string) {
|
func (info *InfoBar) UpdateDataUsageDir(dataUsage string, dataDir string) {
|
||||||
@@ -169,6 +200,11 @@ func (info *InfoBar) UpdateHTTPServer(server string) {
|
|||||||
info.table.GetCell(httpServerRow, valueCol1).SetText(server)
|
info.table.GetCell(httpServerRow, valueCol1).SetText(server)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// UpdateAutoDecrypt updates Auto Decrypt value.
|
||||||
|
func (info *InfoBar) UpdateAutoDecrypt(text string) {
|
||||||
|
info.table.GetCell(httpServerRow, valueCol2).SetText(text)
|
||||||
|
}
|
||||||
|
|
||||||
// Draw draws this primitive onto the screen.
|
// Draw draws this primitive onto the screen.
|
||||||
func (info *InfoBar) Draw(screen tcell.Screen) {
|
func (info *InfoBar) Draw(screen tcell.Screen) {
|
||||||
info.Box.DrawForSubclass(screen, info)
|
info.Box.DrawForSubclass(screen, info)
|
||||||
|
|||||||
@@ -1,415 +0,0 @@
|
|||||||
package wechat
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"crypto/aes"
|
|
||||||
"crypto/cipher"
|
|
||||||
"crypto/hmac"
|
|
||||||
"crypto/sha1"
|
|
||||||
"crypto/sha512"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"hash"
|
|
||||||
"io"
|
|
||||||
"os"
|
|
||||||
|
|
||||||
"golang.org/x/crypto/pbkdf2"
|
|
||||||
)
|
|
||||||
|
|
||||||
// Constants for WeChat database decryption
|
|
||||||
const (
|
|
||||||
// Common constants
|
|
||||||
PageSize = 4096
|
|
||||||
KeySize = 32
|
|
||||||
SaltSize = 16
|
|
||||||
AESBlockSize = 16
|
|
||||||
SQLiteHeader = "SQLite format 3\x00"
|
|
||||||
|
|
||||||
// Version specific constants
|
|
||||||
V3IterCount = 64000
|
|
||||||
V4IterCount = 256000
|
|
||||||
|
|
||||||
IVSize = 16 // Same for both versions
|
|
||||||
HmacSHA1Size = 20 // Used in V3
|
|
||||||
HmacSHA512Size = 64 // Used in V4
|
|
||||||
)
|
|
||||||
|
|
||||||
// Error definitions
|
|
||||||
var (
|
|
||||||
ErrHashVerificationFailed = errors.New("hash verification failed")
|
|
||||||
ErrInvalidVersion = errors.New("invalid version, must be 3 or 4")
|
|
||||||
ErrInvalidKey = errors.New("invalid key format")
|
|
||||||
ErrIncorrectKey = errors.New("incorrect decryption key")
|
|
||||||
ErrReadFile = errors.New("failed to read database file")
|
|
||||||
ErrOpenFile = errors.New("failed to open database file")
|
|
||||||
ErrIncompleteRead = errors.New("incomplete header read")
|
|
||||||
ErrCreateCipher = errors.New("failed to create cipher")
|
|
||||||
ErrDecodeKey = errors.New("failed to decode hex key")
|
|
||||||
ErrWriteOutput = errors.New("failed to write output")
|
|
||||||
ErrSeekFile = errors.New("failed to seek in file")
|
|
||||||
ErrOperationCanceled = errors.New("operation was canceled")
|
|
||||||
ErrAlreadyDecrypted = errors.New("file is already decrypted")
|
|
||||||
)
|
|
||||||
|
|
||||||
// Decryptor handles the decryption of WeChat database files
|
|
||||||
type Decryptor struct {
|
|
||||||
// Database file path
|
|
||||||
dbPath string
|
|
||||||
|
|
||||||
// Database properties
|
|
||||||
version int
|
|
||||||
salt []byte
|
|
||||||
page1 []byte
|
|
||||||
reserve int
|
|
||||||
|
|
||||||
// Calculated fields
|
|
||||||
hashFunc func() hash.Hash
|
|
||||||
hmacSize int
|
|
||||||
currentPage int64
|
|
||||||
totalPages int64
|
|
||||||
}
|
|
||||||
|
|
||||||
// NewDecryptor creates a new Decryptor for the specified database file and version
|
|
||||||
func NewDecryptor(dbPath string, version int) (*Decryptor, error) {
|
|
||||||
// Validate version
|
|
||||||
if version != 3 && version != 4 {
|
|
||||||
return nil, ErrInvalidVersion
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open database file
|
|
||||||
fp, err := os.Open(dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrOpenFile, err)
|
|
||||||
}
|
|
||||||
defer fp.Close()
|
|
||||||
|
|
||||||
// Get file size
|
|
||||||
fileInfo, err := fp.Stat()
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("failed to get file info: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate total pages
|
|
||||||
fileSize := fileInfo.Size()
|
|
||||||
totalPages := fileSize / PageSize
|
|
||||||
if fileSize%PageSize > 0 {
|
|
||||||
totalPages++
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read first page
|
|
||||||
buffer := make([]byte, PageSize)
|
|
||||||
n, err := io.ReadFull(fp, buffer)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrReadFile, err)
|
|
||||||
}
|
|
||||||
if n != PageSize {
|
|
||||||
return nil, fmt.Errorf("%w: expected %d bytes, got %d", ErrIncompleteRead, PageSize, n)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if file is already decrypted
|
|
||||||
if bytes.Equal(buffer[:len(SQLiteHeader)-1], []byte(SQLiteHeader[:len(SQLiteHeader)-1])) {
|
|
||||||
return nil, ErrAlreadyDecrypted
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize hash function and HMAC size based on version
|
|
||||||
var hashFunc func() hash.Hash
|
|
||||||
var hmacSize int
|
|
||||||
|
|
||||||
if version == 4 {
|
|
||||||
hashFunc = sha512.New
|
|
||||||
hmacSize = HmacSHA512Size
|
|
||||||
} else {
|
|
||||||
hashFunc = sha1.New
|
|
||||||
hmacSize = HmacSHA1Size
|
|
||||||
}
|
|
||||||
|
|
||||||
// Calculate reserve size and MAC offset
|
|
||||||
reserve := IVSize + hmacSize
|
|
||||||
if reserve%AESBlockSize != 0 {
|
|
||||||
reserve = ((reserve / AESBlockSize) + 1) * AESBlockSize
|
|
||||||
}
|
|
||||||
|
|
||||||
return &Decryptor{
|
|
||||||
dbPath: dbPath,
|
|
||||||
version: version,
|
|
||||||
salt: buffer[:SaltSize],
|
|
||||||
page1: buffer,
|
|
||||||
reserve: reserve,
|
|
||||||
hashFunc: hashFunc,
|
|
||||||
hmacSize: hmacSize,
|
|
||||||
totalPages: totalPages,
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// GetTotalPages returns the total number of pages in the database
|
|
||||||
func (d *Decryptor) GetTotalPages() int64 {
|
|
||||||
return d.totalPages
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate checks if the provided key is valid for this database
|
|
||||||
func (d *Decryptor) Validate(key []byte) bool {
|
|
||||||
if len(key) != KeySize {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
_, macKey := d.calcPBKDF2Key(key)
|
|
||||||
return d.validate(macKey)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Decryptor) calcPBKDF2Key(key []byte) ([]byte, []byte) {
|
|
||||||
// Generate encryption key from password
|
|
||||||
var encKey []byte
|
|
||||||
if d.version == 4 {
|
|
||||||
encKey = pbkdf2.Key(key, d.salt, V4IterCount, KeySize, sha512.New)
|
|
||||||
} else {
|
|
||||||
encKey = pbkdf2.Key(key, d.salt, V3IterCount, KeySize, sha1.New)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate MAC key
|
|
||||||
macSalt := xorBytes(d.salt, 0x3a)
|
|
||||||
macKey := pbkdf2.Key(encKey, macSalt, 2, KeySize, d.hashFunc)
|
|
||||||
return encKey, macKey
|
|
||||||
}
|
|
||||||
|
|
||||||
func (d *Decryptor) validate(macKey []byte) bool {
|
|
||||||
// Calculate HMAC
|
|
||||||
hashMac := hmac.New(d.hashFunc, macKey)
|
|
||||||
|
|
||||||
dataEnd := PageSize - d.reserve + IVSize
|
|
||||||
hashMac.Write(d.page1[SaltSize:dataEnd])
|
|
||||||
|
|
||||||
// Page number is fixed as 1
|
|
||||||
pageNoBytes := make([]byte, 4)
|
|
||||||
binary.LittleEndian.PutUint32(pageNoBytes, 1)
|
|
||||||
hashMac.Write(pageNoBytes)
|
|
||||||
|
|
||||||
calculatedMAC := hashMac.Sum(nil)
|
|
||||||
storedMAC := d.page1[dataEnd : dataEnd+d.hmacSize]
|
|
||||||
|
|
||||||
return hmac.Equal(calculatedMAC, storedMAC)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt decrypts the database using the provided key and writes the result to the writer
|
|
||||||
func (d *Decryptor) Decrypt(ctx context.Context, hexKey string, w io.Writer) error {
|
|
||||||
// Decode key
|
|
||||||
key, err := hex.DecodeString(hexKey)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrDecodeKey, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
encKey, macKey := d.calcPBKDF2Key(key)
|
|
||||||
|
|
||||||
// Validate key first
|
|
||||||
if !d.validate(macKey) {
|
|
||||||
return ErrIncorrectKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// Open input file
|
|
||||||
dbFile, err := os.Open(d.dbPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrOpenFile, err)
|
|
||||||
}
|
|
||||||
defer dbFile.Close()
|
|
||||||
|
|
||||||
// Write SQLite header to output
|
|
||||||
_, err = w.Write([]byte(SQLiteHeader))
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrWriteOutput, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Process each page
|
|
||||||
pageBuf := make([]byte, PageSize)
|
|
||||||
d.currentPage = 0
|
|
||||||
|
|
||||||
for curPage := int64(0); curPage < d.totalPages; curPage++ {
|
|
||||||
// Check for cancellation before processing each page
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return ErrOperationCanceled
|
|
||||||
default:
|
|
||||||
// Continue processing
|
|
||||||
}
|
|
||||||
|
|
||||||
// For the first page, we need to skip the salt
|
|
||||||
if curPage == 0 {
|
|
||||||
// Read the first page
|
|
||||||
_, err = io.ReadFull(dbFile, pageBuf)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrReadFile, err)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Read a full page
|
|
||||||
n, err := io.ReadFull(dbFile, pageBuf)
|
|
||||||
if err != nil {
|
|
||||||
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
|
||||||
// Handle last partial page
|
|
||||||
if n > 0 {
|
|
||||||
// Process partial page
|
|
||||||
// For simplicity, we'll just break here
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return fmt.Errorf("%w: %v", ErrReadFile, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// check if page contains only zeros (v3 & v4 both have this behavior)
|
|
||||||
allZeros := true
|
|
||||||
for _, b := range pageBuf {
|
|
||||||
if b != 0 {
|
|
||||||
allZeros = false
|
|
||||||
break
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if allZeros {
|
|
||||||
// Write the zeros page to output
|
|
||||||
_, err = w.Write(pageBuf)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrWriteOutput, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update progress
|
|
||||||
d.currentPage = curPage + 1
|
|
||||||
continue
|
|
||||||
|
|
||||||
// // Set current page to total pages to indicate completion
|
|
||||||
// d.currentPage = d.totalPages
|
|
||||||
// return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt the page
|
|
||||||
decryptedPage, err := d.decryptPage(encKey, macKey, pageBuf, curPage)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Write decrypted page to output
|
|
||||||
_, err = w.Write(decryptedPage)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("%w: %v", ErrWriteOutput, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update progress
|
|
||||||
d.currentPage = curPage + 1
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// decryptPage decrypts a single page of the database
|
|
||||||
func (d *Decryptor) decryptPage(key, macKey []byte, pageBuf []byte, pageNum int64) ([]byte, error) {
|
|
||||||
offset := 0
|
|
||||||
if pageNum == 0 {
|
|
||||||
offset = SaltSize
|
|
||||||
}
|
|
||||||
|
|
||||||
// Verify HMAC
|
|
||||||
mac := hmac.New(d.hashFunc, macKey)
|
|
||||||
mac.Write(pageBuf[offset : PageSize-d.reserve+IVSize])
|
|
||||||
|
|
||||||
// Convert page number and update HMAC
|
|
||||||
pageNumBytes := make([]byte, 4)
|
|
||||||
binary.LittleEndian.PutUint32(pageNumBytes, uint32(pageNum+1))
|
|
||||||
mac.Write(pageNumBytes)
|
|
||||||
|
|
||||||
hashMac := mac.Sum(nil)
|
|
||||||
|
|
||||||
hashMacStartOffset := PageSize - d.reserve + IVSize
|
|
||||||
hashMacEndOffset := hashMacStartOffset + len(hashMac)
|
|
||||||
|
|
||||||
if !bytes.Equal(hashMac, pageBuf[hashMacStartOffset:hashMacEndOffset]) {
|
|
||||||
return nil, ErrHashVerificationFailed
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt content using AES-256-CBC
|
|
||||||
iv := pageBuf[PageSize-d.reserve : PageSize-d.reserve+IVSize]
|
|
||||||
block, err := aes.NewCipher(key)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("%w: %v", ErrCreateCipher, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
mode := cipher.NewCBCDecrypter(block, iv)
|
|
||||||
|
|
||||||
// Create a copy of encrypted data for decryption
|
|
||||||
encrypted := make([]byte, PageSize-d.reserve-offset)
|
|
||||||
copy(encrypted, pageBuf[offset:PageSize-d.reserve])
|
|
||||||
|
|
||||||
// Decrypt in place
|
|
||||||
mode.CryptBlocks(encrypted, encrypted)
|
|
||||||
|
|
||||||
// Combine decrypted data with reserve part
|
|
||||||
decryptedPage := append(encrypted, pageBuf[PageSize-d.reserve:PageSize]...)
|
|
||||||
|
|
||||||
return decryptedPage, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// xorBytes performs XOR operation on each byte of the array with the specified byte
|
|
||||||
func xorBytes(a []byte, b byte) []byte {
|
|
||||||
result := make([]byte, len(a))
|
|
||||||
for i := range a {
|
|
||||||
result[i] = a[i] ^ b
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
|
|
||||||
// Utility functions for backward compatibility
|
|
||||||
|
|
||||||
// DecryptDBFile decrypts a WeChat database file and returns the decrypted content
|
|
||||||
func DecryptDBFile(dbPath string, hexKey string, version int) ([]byte, error) {
|
|
||||||
// Create a buffer to store the decrypted content
|
|
||||||
var buf bytes.Buffer
|
|
||||||
|
|
||||||
// Create a decryptor
|
|
||||||
d, err := NewDecryptor(dbPath, version)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt the database
|
|
||||||
err = d.Decrypt(context.Background(), hexKey, &buf)
|
|
||||||
if err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return buf.Bytes(), nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// DecryptDBFileToFile decrypts a WeChat database file and saves the result to the specified output file
|
|
||||||
func DecryptDBFileToFile(dbPath, outputPath, hexKey string, version int) error {
|
|
||||||
// Create output file
|
|
||||||
outputFile, err := os.Create(outputPath)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("failed to create output file: %v", err)
|
|
||||||
}
|
|
||||||
defer outputFile.Close()
|
|
||||||
|
|
||||||
// Create a decryptor
|
|
||||||
d, err := NewDecryptor(dbPath, version)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decrypt the database
|
|
||||||
return d.Decrypt(context.Background(), hexKey, outputFile)
|
|
||||||
}
|
|
||||||
|
|
||||||
// ValidateDBKey validates if the provided key is correct for the database
|
|
||||||
func ValidateDBKey(dbPath string, hexKey string, version int) bool {
|
|
||||||
// Create a decryptor
|
|
||||||
d, err := NewDecryptor(dbPath, version)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Decode key
|
|
||||||
key, err := hex.DecodeString(hexKey)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate the key
|
|
||||||
return d.Validate(key)
|
|
||||||
}
|
|
||||||
138
internal/wechat/decrypt/common/common.go
Normal file
138
internal/wechat/decrypt/common/common.go
Normal file
@@ -0,0 +1,138 @@
|
|||||||
|
package common
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"crypto/aes"
|
||||||
|
"crypto/cipher"
|
||||||
|
"crypto/hmac"
|
||||||
|
"encoding/binary"
|
||||||
|
"fmt"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
KeySize = 32
|
||||||
|
SaltSize = 16
|
||||||
|
AESBlockSize = 16
|
||||||
|
SQLiteHeader = "SQLite format 3\x00"
|
||||||
|
IVSize = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBFile struct {
|
||||||
|
Path string
|
||||||
|
Salt []byte
|
||||||
|
TotalPages int64
|
||||||
|
FirstPage []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func OpenDBFile(dbPath string, pageSize int) (*DBFile, error) {
|
||||||
|
fp, err := os.Open(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.OpenFileFailed(dbPath, err)
|
||||||
|
}
|
||||||
|
defer fp.Close()
|
||||||
|
|
||||||
|
fileInfo, err := fp.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.StatFileFailed(dbPath, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
fileSize := fileInfo.Size()
|
||||||
|
totalPages := fileSize / int64(pageSize)
|
||||||
|
if fileSize%int64(pageSize) > 0 {
|
||||||
|
totalPages++
|
||||||
|
}
|
||||||
|
|
||||||
|
buffer := make([]byte, pageSize)
|
||||||
|
n, err := io.ReadFull(fp, buffer)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ReadFileFailed(dbPath, err)
|
||||||
|
}
|
||||||
|
if n != pageSize {
|
||||||
|
return nil, errors.IncompleteRead(fmt.Errorf("read %d bytes, expected %d", n, pageSize))
|
||||||
|
}
|
||||||
|
|
||||||
|
if bytes.Equal(buffer[:len(SQLiteHeader)-1], []byte(SQLiteHeader[:len(SQLiteHeader)-1])) {
|
||||||
|
return nil, errors.ErrAlreadyDecrypted
|
||||||
|
}
|
||||||
|
|
||||||
|
return &DBFile{
|
||||||
|
Path: dbPath,
|
||||||
|
Salt: buffer[:SaltSize],
|
||||||
|
FirstPage: buffer,
|
||||||
|
TotalPages: totalPages,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func XorBytes(a []byte, b byte) []byte {
|
||||||
|
result := make([]byte, len(a))
|
||||||
|
for i := range a {
|
||||||
|
result[i] = a[i] ^ b
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func ValidateKey(page1 []byte, key []byte, salt []byte, hashFunc func() hash.Hash, hmacSize int, reserve int, pageSize int, deriveKeys func([]byte, []byte) ([]byte, []byte)) bool {
|
||||||
|
if len(key) != KeySize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
_, macKey := deriveKeys(key, salt)
|
||||||
|
|
||||||
|
mac := hmac.New(hashFunc, macKey)
|
||||||
|
dataEnd := pageSize - reserve + IVSize
|
||||||
|
mac.Write(page1[SaltSize:dataEnd])
|
||||||
|
|
||||||
|
pageNoBytes := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(pageNoBytes, 1)
|
||||||
|
mac.Write(pageNoBytes)
|
||||||
|
|
||||||
|
calculatedMAC := mac.Sum(nil)
|
||||||
|
storedMAC := page1[dataEnd : dataEnd+hmacSize]
|
||||||
|
|
||||||
|
return hmac.Equal(calculatedMAC, storedMAC)
|
||||||
|
}
|
||||||
|
|
||||||
|
func DecryptPage(pageBuf []byte, encKey []byte, macKey []byte, pageNum int64, hashFunc func() hash.Hash, hmacSize int, reserve int, pageSize int) ([]byte, error) {
|
||||||
|
offset := 0
|
||||||
|
if pageNum == 0 {
|
||||||
|
offset = SaltSize
|
||||||
|
}
|
||||||
|
|
||||||
|
mac := hmac.New(hashFunc, macKey)
|
||||||
|
mac.Write(pageBuf[offset : pageSize-reserve+IVSize])
|
||||||
|
|
||||||
|
pageNoBytes := make([]byte, 4)
|
||||||
|
binary.LittleEndian.PutUint32(pageNoBytes, uint32(pageNum+1))
|
||||||
|
mac.Write(pageNoBytes)
|
||||||
|
|
||||||
|
hashMac := mac.Sum(nil)
|
||||||
|
|
||||||
|
hashMacStartOffset := pageSize - reserve + IVSize
|
||||||
|
hashMacEndOffset := hashMacStartOffset + hmacSize
|
||||||
|
|
||||||
|
if !bytes.Equal(hashMac, pageBuf[hashMacStartOffset:hashMacEndOffset]) {
|
||||||
|
return nil, errors.ErrDecryptHashVerificationFailed
|
||||||
|
}
|
||||||
|
|
||||||
|
iv := pageBuf[pageSize-reserve : pageSize-reserve+IVSize]
|
||||||
|
block, err := aes.NewCipher(encKey)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.DecryptCreateCipherFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
mode := cipher.NewCBCDecrypter(block, iv)
|
||||||
|
|
||||||
|
encrypted := make([]byte, pageSize-reserve-offset)
|
||||||
|
copy(encrypted, pageBuf[offset:pageSize-reserve])
|
||||||
|
|
||||||
|
mode.CryptBlocks(encrypted, encrypted)
|
||||||
|
|
||||||
|
decryptedPage := append(encrypted, pageBuf[pageSize-reserve:pageSize]...)
|
||||||
|
|
||||||
|
return decryptedPage, nil
|
||||||
|
}
|
||||||
185
internal/wechat/decrypt/darwin/v3.go
Normal file
185
internal/wechat/decrypt/darwin/v3.go
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
package darwin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 常量定义
|
||||||
|
const (
|
||||||
|
V3PageSize = 1024
|
||||||
|
HmacSHA1Size = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// V3Decryptor 实现 macOS V3 版本的解密器
|
||||||
|
type V3Decryptor struct {
|
||||||
|
// macOS V3 特定参数
|
||||||
|
hmacSize int
|
||||||
|
hashFunc func() hash.Hash
|
||||||
|
reserve int
|
||||||
|
pageSize int
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewV3Decryptor 创建 macOS V3 解密器
|
||||||
|
func NewV3Decryptor() *V3Decryptor {
|
||||||
|
hashFunc := sha1.New
|
||||||
|
hmacSize := HmacSHA1Size
|
||||||
|
reserve := common.IVSize + hmacSize
|
||||||
|
if reserve%common.AESBlockSize != 0 {
|
||||||
|
reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return &V3Decryptor{
|
||||||
|
hmacSize: hmacSize,
|
||||||
|
hashFunc: hashFunc,
|
||||||
|
reserve: reserve,
|
||||||
|
pageSize: V3PageSize,
|
||||||
|
version: "macOS v3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveKeys 派生 MAC 密钥
|
||||||
|
// 注意:macOS V3 版本直接使用提供的密钥作为加密密钥,不进行 PBKDF2 派生
|
||||||
|
func (d *V3Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
|
||||||
|
// 对于 macOS V3,直接使用密钥作为加密密钥
|
||||||
|
encKey := key
|
||||||
|
|
||||||
|
// 生成 MAC 密钥
|
||||||
|
macSalt := common.XorBytes(salt, 0x3a)
|
||||||
|
macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
|
||||||
|
|
||||||
|
return encKey, macKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 验证密钥是否有效
|
||||||
|
func (d *V3Decryptor) Validate(page1 []byte, key []byte) bool {
|
||||||
|
if len(page1) < d.pageSize || len(key) != common.KeySize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
salt := page1[:common.SaltSize]
|
||||||
|
return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt 解密数据库
|
||||||
|
func (d *V3Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
|
||||||
|
// 解码密钥
|
||||||
|
key, err := hex.DecodeString(hexKey)
|
||||||
|
if err != nil {
|
||||||
|
return errors.DecodeKeyFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开数据库文件并读取基本信息
|
||||||
|
dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密钥
|
||||||
|
if !d.Validate(dbInfo.FirstPage, key) {
|
||||||
|
return errors.ErrDecryptIncorrectKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算密钥
|
||||||
|
encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
|
||||||
|
|
||||||
|
// 打开数据库文件
|
||||||
|
dbFile, err := os.Open(dbfile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.OpenFileFailed(dbfile, err)
|
||||||
|
}
|
||||||
|
defer dbFile.Close()
|
||||||
|
|
||||||
|
// 写入 SQLite 头
|
||||||
|
_, err = output.Write([]byte(common.SQLiteHeader))
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每一页
|
||||||
|
pageBuf := make([]byte, d.pageSize)
|
||||||
|
|
||||||
|
for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
|
||||||
|
// 检查是否取消
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return errors.ErrDecryptOperationCanceled
|
||||||
|
default:
|
||||||
|
// 继续处理
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取一页
|
||||||
|
n, err := io.ReadFull(dbFile, pageBuf)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||||
|
// 处理最后一部分页面
|
||||||
|
if n > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.ReadFileFailed(dbfile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查页面是否全为零
|
||||||
|
allZeros := true
|
||||||
|
for _, b := range pageBuf {
|
||||||
|
if b != 0 {
|
||||||
|
allZeros = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allZeros {
|
||||||
|
// 写入零页面
|
||||||
|
_, err = output.Write(pageBuf)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密页面
|
||||||
|
decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入解密后的页面
|
||||||
|
_, err = output.Write(decryptedData)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPageSize 返回页面大小
|
||||||
|
func (d *V3Decryptor) GetPageSize() int {
|
||||||
|
return d.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReserve 返回保留字节数
|
||||||
|
func (d *V3Decryptor) GetReserve() int {
|
||||||
|
return d.reserve
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHMACSize 返回HMAC大小
|
||||||
|
func (d *V3Decryptor) GetHMACSize() int {
|
||||||
|
return d.hmacSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion 返回解密器版本
|
||||||
|
func (d *V3Decryptor) GetVersion() string {
|
||||||
|
return d.version
|
||||||
|
}
|
||||||
194
internal/wechat/decrypt/darwin/v4.go
Normal file
194
internal/wechat/decrypt/darwin/v4.go
Normal file
@@ -0,0 +1,194 @@
|
|||||||
|
package darwin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/hex"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Darwin Version 4 same as WIndows Version 4
|
||||||
|
|
||||||
|
// V4 版本特定常量
|
||||||
|
const (
|
||||||
|
V4PageSize = 4096
|
||||||
|
V4IterCount = 256000
|
||||||
|
HmacSHA512Size = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// V4Decryptor 实现Windows V4版本的解密器
|
||||||
|
type V4Decryptor struct {
|
||||||
|
// V4 特定参数
|
||||||
|
iterCount int
|
||||||
|
hmacSize int
|
||||||
|
hashFunc func() hash.Hash
|
||||||
|
reserve int
|
||||||
|
pageSize int
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewV4Decryptor 创建Windows V4解密器
|
||||||
|
func NewV4Decryptor() *V4Decryptor {
|
||||||
|
hashFunc := sha512.New
|
||||||
|
hmacSize := HmacSHA512Size
|
||||||
|
reserve := common.IVSize + hmacSize
|
||||||
|
if reserve%common.AESBlockSize != 0 {
|
||||||
|
reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return &V4Decryptor{
|
||||||
|
iterCount: V4IterCount,
|
||||||
|
hmacSize: hmacSize,
|
||||||
|
hashFunc: hashFunc,
|
||||||
|
reserve: reserve,
|
||||||
|
pageSize: V4PageSize,
|
||||||
|
version: "macOS v4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveKeys 派生加密密钥和MAC密钥
|
||||||
|
func (d *V4Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
|
||||||
|
// 生成加密密钥
|
||||||
|
encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc)
|
||||||
|
|
||||||
|
// 生成MAC密钥
|
||||||
|
macSalt := common.XorBytes(salt, 0x3a)
|
||||||
|
macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
|
||||||
|
|
||||||
|
return encKey, macKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 验证密钥是否有效
|
||||||
|
func (d *V4Decryptor) Validate(page1 []byte, key []byte) bool {
|
||||||
|
if len(page1) < d.pageSize || len(key) != common.KeySize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
salt := page1[:common.SaltSize]
|
||||||
|
return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt 解密数据库
|
||||||
|
func (d *V4Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
|
||||||
|
// 解码密钥
|
||||||
|
key, err := hex.DecodeString(hexKey)
|
||||||
|
if err != nil {
|
||||||
|
return errors.DecodeKeyFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开数据库文件并读取基本信息
|
||||||
|
dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密钥
|
||||||
|
if !d.Validate(dbInfo.FirstPage, key) {
|
||||||
|
return errors.ErrDecryptIncorrectKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算密钥
|
||||||
|
encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
|
||||||
|
|
||||||
|
// 打开数据库文件
|
||||||
|
dbFile, err := os.Open(dbfile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.OpenFileFailed(dbfile, err)
|
||||||
|
}
|
||||||
|
defer dbFile.Close()
|
||||||
|
|
||||||
|
// 写入SQLite头
|
||||||
|
_, err = output.Write([]byte(common.SQLiteHeader))
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每一页
|
||||||
|
pageBuf := make([]byte, d.pageSize)
|
||||||
|
|
||||||
|
for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
|
||||||
|
// 检查是否取消
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return errors.ErrDecryptOperationCanceled
|
||||||
|
default:
|
||||||
|
// 继续处理
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取一页
|
||||||
|
n, err := io.ReadFull(dbFile, pageBuf)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||||
|
// 处理最后一部分页面
|
||||||
|
if n > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.ReadFileFailed(dbfile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查页面是否全为零
|
||||||
|
allZeros := true
|
||||||
|
for _, b := range pageBuf {
|
||||||
|
if b != 0 {
|
||||||
|
allZeros = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allZeros {
|
||||||
|
// 写入零页面
|
||||||
|
_, err = output.Write(pageBuf)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密页面
|
||||||
|
decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入解密后的页面
|
||||||
|
_, err = output.Write(decryptedData)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPageSize 返回页面大小
|
||||||
|
func (d *V4Decryptor) GetPageSize() int {
|
||||||
|
return d.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReserve 返回保留字节数
|
||||||
|
func (d *V4Decryptor) GetReserve() int {
|
||||||
|
return d.reserve
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHMACSize 返回HMAC大小
|
||||||
|
func (d *V4Decryptor) GetHMACSize() int {
|
||||||
|
return d.hmacSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion 返回解密器版本
|
||||||
|
func (d *V4Decryptor) GetVersion() string {
|
||||||
|
return d.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIterCount 返回迭代次数(Windows特有)
|
||||||
|
func (d *V4Decryptor) GetIterCount() int {
|
||||||
|
return d.iterCount
|
||||||
|
}
|
||||||
48
internal/wechat/decrypt/decryptor.go
Normal file
48
internal/wechat/decrypt/decryptor.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package decrypt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt/darwin"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Decryptor 定义数据库解密的接口
|
||||||
|
type Decryptor interface {
|
||||||
|
// Decrypt 解密数据库
|
||||||
|
Decrypt(ctx context.Context, dbfile string, key string, output io.Writer) error
|
||||||
|
|
||||||
|
// Validate 验证密钥是否有效
|
||||||
|
Validate(page1 []byte, key []byte) bool
|
||||||
|
|
||||||
|
// GetPageSize 返回页面大小
|
||||||
|
GetPageSize() int
|
||||||
|
|
||||||
|
// GetReserve 返回保留字节数
|
||||||
|
GetReserve() int
|
||||||
|
|
||||||
|
// GetHMACSize 返回HMAC大小
|
||||||
|
GetHMACSize() int
|
||||||
|
|
||||||
|
// GetVersion 返回解密器版本
|
||||||
|
GetVersion() string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDecryptor 创建一个新的解密器
|
||||||
|
func NewDecryptor(platform string, version int) (Decryptor, error) {
|
||||||
|
// 根据平台返回对应的实现
|
||||||
|
switch {
|
||||||
|
case platform == "windows" && version == 3:
|
||||||
|
return windows.NewV3Decryptor(), nil
|
||||||
|
case platform == "windows" && version == 4:
|
||||||
|
return windows.NewV4Decryptor(), nil
|
||||||
|
case platform == "darwin" && version == 3:
|
||||||
|
return darwin.NewV3Decryptor(), nil
|
||||||
|
case platform == "darwin" && version == 4:
|
||||||
|
return darwin.NewV4Decryptor(), nil
|
||||||
|
default:
|
||||||
|
return nil, errors.PlatformUnsupported(platform, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
60
internal/wechat/decrypt/validator.go
Normal file
60
internal/wechat/decrypt/validator.go
Normal file
@@ -0,0 +1,60 @@
|
|||||||
|
package decrypt
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Validator struct {
|
||||||
|
platform string
|
||||||
|
version int
|
||||||
|
dbPath string
|
||||||
|
decryptor Decryptor
|
||||||
|
dbFile *common.DBFile
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewValidator 创建一个仅用于验证的验证器
|
||||||
|
func NewValidator(platform string, version int, dataDir string) (*Validator, error) {
|
||||||
|
dbFile := GetSimpleDBFile(platform, version)
|
||||||
|
dbPath := filepath.Join(dataDir + "/" + dbFile)
|
||||||
|
return NewValidatorWithFile(platform, version, dbPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewValidatorWithFile(platform string, version int, dbPath string) (*Validator, error) {
|
||||||
|
decryptor, err := NewDecryptor(platform, version)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d, err := common.OpenDBFile(dbPath, decryptor.GetPageSize())
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Validator{
|
||||||
|
platform: platform,
|
||||||
|
version: version,
|
||||||
|
dbPath: dbPath,
|
||||||
|
decryptor: decryptor,
|
||||||
|
dbFile: d,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *Validator) Validate(key []byte) bool {
|
||||||
|
return v.decryptor.Validate(v.dbFile.FirstPage, key)
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetSimpleDBFile(platform string, version int) string {
|
||||||
|
switch {
|
||||||
|
case platform == "windows" && version == 3:
|
||||||
|
return "Msg\\Misc.db"
|
||||||
|
case platform == "windows" && version == 4:
|
||||||
|
return "db_storage\\message\\message_0.db"
|
||||||
|
case platform == "darwin" && version == 3:
|
||||||
|
return "Message/msg_0.db"
|
||||||
|
case platform == "darwin" && version == 4:
|
||||||
|
return "db_storage/message/message_0.db"
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
|
||||||
|
}
|
||||||
192
internal/wechat/decrypt/windows/v3.go
Normal file
192
internal/wechat/decrypt/windows/v3.go
Normal file
@@ -0,0 +1,192 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha1"
|
||||||
|
"encoding/hex"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// V3 版本特定常量
|
||||||
|
const (
|
||||||
|
PageSize = 4096
|
||||||
|
V3IterCount = 64000
|
||||||
|
HmacSHA1Size = 20
|
||||||
|
)
|
||||||
|
|
||||||
|
// V3Decryptor 实现Windows V3版本的解密器
|
||||||
|
type V3Decryptor struct {
|
||||||
|
// V3 特定参数
|
||||||
|
iterCount int
|
||||||
|
hmacSize int
|
||||||
|
hashFunc func() hash.Hash
|
||||||
|
reserve int
|
||||||
|
pageSize int
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewV3Decryptor 创建Windows V3解密器
|
||||||
|
func NewV3Decryptor() *V3Decryptor {
|
||||||
|
hashFunc := sha1.New
|
||||||
|
hmacSize := HmacSHA1Size
|
||||||
|
reserve := common.IVSize + hmacSize
|
||||||
|
if reserve%common.AESBlockSize != 0 {
|
||||||
|
reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return &V3Decryptor{
|
||||||
|
iterCount: V3IterCount,
|
||||||
|
hmacSize: hmacSize,
|
||||||
|
hashFunc: hashFunc,
|
||||||
|
reserve: reserve,
|
||||||
|
pageSize: PageSize,
|
||||||
|
version: "Windows v3",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveKeys 派生加密密钥和MAC密钥
|
||||||
|
func (d *V3Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
|
||||||
|
// 生成加密密钥
|
||||||
|
encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc)
|
||||||
|
|
||||||
|
// 生成MAC密钥
|
||||||
|
macSalt := common.XorBytes(salt, 0x3a)
|
||||||
|
macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
|
||||||
|
|
||||||
|
return encKey, macKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 验证密钥是否有效
|
||||||
|
func (d *V3Decryptor) Validate(page1 []byte, key []byte) bool {
|
||||||
|
if len(page1) < d.pageSize || len(key) != common.KeySize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
salt := page1[:common.SaltSize]
|
||||||
|
return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt 解密数据库
|
||||||
|
func (d *V3Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
|
||||||
|
// 解码密钥
|
||||||
|
key, err := hex.DecodeString(hexKey)
|
||||||
|
if err != nil {
|
||||||
|
return errors.DecodeKeyFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开数据库文件并读取基本信息
|
||||||
|
dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密钥
|
||||||
|
if !d.Validate(dbInfo.FirstPage, key) {
|
||||||
|
return errors.ErrDecryptIncorrectKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算密钥
|
||||||
|
encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
|
||||||
|
|
||||||
|
// 打开数据库文件
|
||||||
|
dbFile, err := os.Open(dbfile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.OpenFileFailed(dbfile, err)
|
||||||
|
}
|
||||||
|
defer dbFile.Close()
|
||||||
|
|
||||||
|
// 写入SQLite头
|
||||||
|
_, err = output.Write([]byte(common.SQLiteHeader))
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每一页
|
||||||
|
pageBuf := make([]byte, d.pageSize)
|
||||||
|
|
||||||
|
for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
|
||||||
|
// 检查是否取消
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return errors.ErrDecryptOperationCanceled
|
||||||
|
default:
|
||||||
|
// 继续处理
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取一页
|
||||||
|
n, err := io.ReadFull(dbFile, pageBuf)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||||
|
// 处理最后一部分页面
|
||||||
|
if n > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.ReadFileFailed(dbfile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查页面是否全为零
|
||||||
|
allZeros := true
|
||||||
|
for _, b := range pageBuf {
|
||||||
|
if b != 0 {
|
||||||
|
allZeros = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allZeros {
|
||||||
|
// 写入零页面
|
||||||
|
_, err = output.Write(pageBuf)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密页面
|
||||||
|
decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入解密后的页面
|
||||||
|
_, err = output.Write(decryptedData)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPageSize 返回页面大小
|
||||||
|
func (d *V3Decryptor) GetPageSize() int {
|
||||||
|
return d.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReserve 返回保留字节数
|
||||||
|
func (d *V3Decryptor) GetReserve() int {
|
||||||
|
return d.reserve
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHMACSize 返回HMAC大小
|
||||||
|
func (d *V3Decryptor) GetHMACSize() int {
|
||||||
|
return d.hmacSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion 返回解密器版本
|
||||||
|
func (d *V3Decryptor) GetVersion() string {
|
||||||
|
return d.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIterCount 返回迭代次数(Windows特有)
|
||||||
|
func (d *V3Decryptor) GetIterCount() int {
|
||||||
|
return d.iterCount
|
||||||
|
}
|
||||||
191
internal/wechat/decrypt/windows/v4.go
Normal file
191
internal/wechat/decrypt/windows/v4.go
Normal file
@@ -0,0 +1,191 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/sha512"
|
||||||
|
"encoding/hex"
|
||||||
|
"hash"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt/common"
|
||||||
|
|
||||||
|
"golang.org/x/crypto/pbkdf2"
|
||||||
|
)
|
||||||
|
|
||||||
|
// V4 版本特定常量
|
||||||
|
const (
|
||||||
|
V4IterCount = 256000
|
||||||
|
HmacSHA512Size = 64
|
||||||
|
)
|
||||||
|
|
||||||
|
// V4Decryptor 实现Windows V4版本的解密器
|
||||||
|
type V4Decryptor struct {
|
||||||
|
// V4 特定参数
|
||||||
|
iterCount int
|
||||||
|
hmacSize int
|
||||||
|
hashFunc func() hash.Hash
|
||||||
|
reserve int
|
||||||
|
pageSize int
|
||||||
|
version string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewV4Decryptor 创建Windows V4解密器
|
||||||
|
func NewV4Decryptor() *V4Decryptor {
|
||||||
|
hashFunc := sha512.New
|
||||||
|
hmacSize := HmacSHA512Size
|
||||||
|
reserve := common.IVSize + hmacSize
|
||||||
|
if reserve%common.AESBlockSize != 0 {
|
||||||
|
reserve = ((reserve / common.AESBlockSize) + 1) * common.AESBlockSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return &V4Decryptor{
|
||||||
|
iterCount: V4IterCount,
|
||||||
|
hmacSize: hmacSize,
|
||||||
|
hashFunc: hashFunc,
|
||||||
|
reserve: reserve,
|
||||||
|
pageSize: PageSize,
|
||||||
|
version: "Windows v4",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// deriveKeys 派生加密密钥和MAC密钥
|
||||||
|
func (d *V4Decryptor) deriveKeys(key []byte, salt []byte) ([]byte, []byte) {
|
||||||
|
// 生成加密密钥
|
||||||
|
encKey := pbkdf2.Key(key, salt, d.iterCount, common.KeySize, d.hashFunc)
|
||||||
|
|
||||||
|
// 生成MAC密钥
|
||||||
|
macSalt := common.XorBytes(salt, 0x3a)
|
||||||
|
macKey := pbkdf2.Key(encKey, macSalt, 2, common.KeySize, d.hashFunc)
|
||||||
|
|
||||||
|
return encKey, macKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate 验证密钥是否有效
|
||||||
|
func (d *V4Decryptor) Validate(page1 []byte, key []byte) bool {
|
||||||
|
if len(page1) < d.pageSize || len(key) != common.KeySize {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
salt := page1[:common.SaltSize]
|
||||||
|
return common.ValidateKey(page1, key, salt, d.hashFunc, d.hmacSize, d.reserve, d.pageSize, d.deriveKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decrypt 解密数据库
|
||||||
|
func (d *V4Decryptor) Decrypt(ctx context.Context, dbfile string, hexKey string, output io.Writer) error {
|
||||||
|
// 解码密钥
|
||||||
|
key, err := hex.DecodeString(hexKey)
|
||||||
|
if err != nil {
|
||||||
|
return errors.DecodeKeyFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 打开数据库文件并读取基本信息
|
||||||
|
dbInfo, err := common.OpenDBFile(dbfile, d.pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证密钥
|
||||||
|
if !d.Validate(dbInfo.FirstPage, key) {
|
||||||
|
return errors.ErrDecryptIncorrectKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// 计算密钥
|
||||||
|
encKey, macKey := d.deriveKeys(key, dbInfo.Salt)
|
||||||
|
|
||||||
|
// 打开数据库文件
|
||||||
|
dbFile, err := os.Open(dbfile)
|
||||||
|
if err != nil {
|
||||||
|
return errors.OpenFileFailed(dbfile, err)
|
||||||
|
}
|
||||||
|
defer dbFile.Close()
|
||||||
|
|
||||||
|
// 写入SQLite头
|
||||||
|
_, err = output.Write([]byte(common.SQLiteHeader))
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每一页
|
||||||
|
pageBuf := make([]byte, d.pageSize)
|
||||||
|
|
||||||
|
for curPage := int64(0); curPage < dbInfo.TotalPages; curPage++ {
|
||||||
|
// 检查是否取消
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return errors.ErrDecryptOperationCanceled
|
||||||
|
default:
|
||||||
|
// 继续处理
|
||||||
|
}
|
||||||
|
|
||||||
|
// 读取一页
|
||||||
|
n, err := io.ReadFull(dbFile, pageBuf)
|
||||||
|
if err != nil {
|
||||||
|
if err == io.EOF || err == io.ErrUnexpectedEOF {
|
||||||
|
// 处理最后一部分页面
|
||||||
|
if n > 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return errors.ReadFileFailed(dbfile, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查页面是否全为零
|
||||||
|
allZeros := true
|
||||||
|
for _, b := range pageBuf {
|
||||||
|
if b != 0 {
|
||||||
|
allZeros = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if allZeros {
|
||||||
|
// 写入零页面
|
||||||
|
_, err = output.Write(pageBuf)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解密页面
|
||||||
|
decryptedData, err := common.DecryptPage(pageBuf, encKey, macKey, curPage, d.hashFunc, d.hmacSize, d.reserve, d.pageSize)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 写入解密后的页面
|
||||||
|
_, err = output.Write(decryptedData)
|
||||||
|
if err != nil {
|
||||||
|
return errors.WriteOutputFailed(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetPageSize 返回页面大小
|
||||||
|
func (d *V4Decryptor) GetPageSize() int {
|
||||||
|
return d.pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetReserve 返回保留字节数
|
||||||
|
func (d *V4Decryptor) GetReserve() int {
|
||||||
|
return d.reserve
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHMACSize 返回HMAC大小
|
||||||
|
func (d *V4Decryptor) GetHMACSize() int {
|
||||||
|
return d.hmacSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetVersion 返回解密器版本
|
||||||
|
func (d *V4Decryptor) GetVersion() string {
|
||||||
|
return d.version
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetIterCount 返回迭代次数(Windows特有)
|
||||||
|
func (d *V4Decryptor) GetIterCount() int {
|
||||||
|
return d.iterCount
|
||||||
|
}
|
||||||
@@ -1,50 +0,0 @@
|
|||||||
package wechat
|
|
||||||
|
|
||||||
import (
|
|
||||||
"github.com/sjzar/chatlog/pkg/dllver"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/process"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
StatusInit = ""
|
|
||||||
StatusOffline = "offline"
|
|
||||||
StatusOnline = "online"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Info struct {
|
|
||||||
PID uint32
|
|
||||||
ExePath string
|
|
||||||
Version *dllver.Info
|
|
||||||
Status string
|
|
||||||
DataDir string
|
|
||||||
AccountName string
|
|
||||||
Key string
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewInfo(p *process.Process) (*Info, error) {
|
|
||||||
info := &Info{
|
|
||||||
PID: uint32(p.Pid),
|
|
||||||
Status: StatusOffline,
|
|
||||||
}
|
|
||||||
|
|
||||||
var err error
|
|
||||||
info.ExePath, err = p.Exe()
|
|
||||||
if err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
info.Version, err = dllver.New(info.ExePath)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := info.initialize(p); err != nil {
|
|
||||||
return nil, err
|
|
||||||
}
|
|
||||||
|
|
||||||
return info, nil
|
|
||||||
}
|
|
||||||
@@ -1,16 +0,0 @@
|
|||||||
//go:build !windows
|
|
||||||
|
|
||||||
package wechat
|
|
||||||
|
|
||||||
import "github.com/shirou/gopsutil/v4/process"
|
|
||||||
|
|
||||||
// Giao~
|
|
||||||
// 还没来得及写,Mac 版本打算通过 vmmap 检查内存区域,再用 lldb 读取内存来检查 Key,需要关 SIP 或自签名应用,稍晚再填坑
|
|
||||||
|
|
||||||
func (i *Info) initialize(p *process.Process) error {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (i *Info) GetKey() (string, error) {
|
|
||||||
return "mock-key", nil
|
|
||||||
}
|
|
||||||
@@ -1,507 +0,0 @@
|
|||||||
package wechat
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bytes"
|
|
||||||
"context"
|
|
||||||
"encoding/binary"
|
|
||||||
"encoding/hex"
|
|
||||||
"errors"
|
|
||||||
"fmt"
|
|
||||||
"path/filepath"
|
|
||||||
"runtime"
|
|
||||||
"strings"
|
|
||||||
"sync"
|
|
||||||
"unsafe"
|
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/pkg/util"
|
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/process"
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
"golang.org/x/sys/windows"
|
|
||||||
)
|
|
||||||
|
|
||||||
const (
|
|
||||||
V3ModuleName = "WeChatWin.dll"
|
|
||||||
V3DBFile = "Msg\\Misc.db"
|
|
||||||
V4DBFile = "db_storage\\message\\message_0.db"
|
|
||||||
|
|
||||||
MaxWorkers = 16
|
|
||||||
|
|
||||||
// Windows memory protection constants
|
|
||||||
MEM_PRIVATE = 0x20000
|
|
||||||
)
|
|
||||||
|
|
||||||
// Common error definitions
|
|
||||||
var (
|
|
||||||
ErrWeChatOffline = errors.New("wechat is not logged in")
|
|
||||||
ErrOpenProcess = errors.New("failed to open process")
|
|
||||||
ErrReaddecryptor = errors.New("failed to read database header")
|
|
||||||
ErrCheckProcessBits = errors.New("failed to check process architecture")
|
|
||||||
ErrFindWeChatDLL = errors.New("WeChatWin.dll module not found")
|
|
||||||
ErrNoValidKey = errors.New("no valid key found")
|
|
||||||
ErrInvalidFilePath = errors.New("invalid file path format")
|
|
||||||
)
|
|
||||||
|
|
||||||
// GetKey is the entry point for retrieving the WeChat database key
|
|
||||||
func (i *Info) GetKey() (string, error) {
|
|
||||||
if i.Status == StatusOffline {
|
|
||||||
return "", ErrWeChatOffline
|
|
||||||
}
|
|
||||||
|
|
||||||
// Choose key retrieval method based on WeChat version
|
|
||||||
if i.Version.FileMajorVersion == 4 {
|
|
||||||
return i.getKeyV4()
|
|
||||||
}
|
|
||||||
return i.getKeyV3()
|
|
||||||
}
|
|
||||||
|
|
||||||
// initialize initializes WeChat information
|
|
||||||
func (i *Info) initialize(p *process.Process) error {
|
|
||||||
files, err := p.OpenFiles()
|
|
||||||
if err != nil {
|
|
||||||
log.Error("Failed to get open file list: ", err)
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
dbPath := V3DBFile
|
|
||||||
if i.Version.FileMajorVersion == 4 {
|
|
||||||
dbPath = V4DBFile
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, f := range files {
|
|
||||||
if strings.HasSuffix(f.Path, dbPath) {
|
|
||||||
filePath := f.Path[4:] // Remove "\\?\" prefix
|
|
||||||
parts := strings.Split(filePath, string(filepath.Separator))
|
|
||||||
if len(parts) < 4 {
|
|
||||||
log.Debug("Invalid file path format: " + filePath)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
i.Status = StatusOnline
|
|
||||||
if i.Version.FileMajorVersion == 4 {
|
|
||||||
i.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator))
|
|
||||||
i.AccountName = parts[len(parts)-4]
|
|
||||||
} else {
|
|
||||||
i.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator))
|
|
||||||
i.AccountName = parts[len(parts)-3]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// getKeyV3 retrieves the database key for WeChat V3 version
|
|
||||||
func (i *Info) getKeyV3() (string, error) {
|
|
||||||
// Read database header for key validation
|
|
||||||
dbPath := filepath.Join(i.DataDir, V3DBFile)
|
|
||||||
decryptor, err := NewDecryptor(dbPath, i.Version.FileMajorVersion)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %v", ErrReaddecryptor, err)
|
|
||||||
}
|
|
||||||
log.Debug("V3 database path: ", dbPath)
|
|
||||||
|
|
||||||
// Open WeChat process
|
|
||||||
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, i.PID)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %v", ErrOpenProcess, err)
|
|
||||||
}
|
|
||||||
defer windows.CloseHandle(handle)
|
|
||||||
|
|
||||||
// Check process architecture
|
|
||||||
is64Bit, err := util.Is64Bit(handle)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %v", ErrCheckProcessBits, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create context to control all goroutines
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create channels for memory data and results
|
|
||||||
memoryChannel := make(chan []byte, 100)
|
|
||||||
resultChannel := make(chan string, 1)
|
|
||||||
|
|
||||||
// Determine number of worker goroutines
|
|
||||||
workerCount := runtime.NumCPU()
|
|
||||||
if workerCount < 2 {
|
|
||||||
workerCount = 2
|
|
||||||
}
|
|
||||||
if workerCount > MaxWorkers {
|
|
||||||
workerCount = MaxWorkers
|
|
||||||
}
|
|
||||||
log.Debug("Starting ", workerCount, " workers for V3 key search")
|
|
||||||
|
|
||||||
// Start consumer goroutines
|
|
||||||
var workerWaitGroup sync.WaitGroup
|
|
||||||
workerWaitGroup.Add(workerCount)
|
|
||||||
for index := 0; index < workerCount; index++ {
|
|
||||||
go func() {
|
|
||||||
defer workerWaitGroup.Done()
|
|
||||||
workerV3(ctx, handle, decryptor, is64Bit, memoryChannel, resultChannel)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start producer goroutine
|
|
||||||
var producerWaitGroup sync.WaitGroup
|
|
||||||
producerWaitGroup.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer producerWaitGroup.Done()
|
|
||||||
defer close(memoryChannel) // Close channel when producer is done
|
|
||||||
err := i.findMemoryV3(ctx, handle, memoryChannel)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for producer and consumers to complete
|
|
||||||
go func() {
|
|
||||||
producerWaitGroup.Wait()
|
|
||||||
workerWaitGroup.Wait()
|
|
||||||
close(resultChannel)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for result
|
|
||||||
result, ok := <-resultChannel
|
|
||||||
if ok && result != "" {
|
|
||||||
i.Key = result
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", ErrNoValidKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// getKeyV4 retrieves the database key for WeChat V4 version
|
|
||||||
func (i *Info) getKeyV4() (string, error) {
|
|
||||||
// Read database header for key validation
|
|
||||||
dbPath := filepath.Join(i.DataDir, V4DBFile)
|
|
||||||
decryptor, err := NewDecryptor(dbPath, i.Version.FileMajorVersion)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %v", ErrReaddecryptor, err)
|
|
||||||
}
|
|
||||||
log.Debug("V4 database path: ", dbPath)
|
|
||||||
|
|
||||||
// Open process handle
|
|
||||||
handle, err := windows.OpenProcess(windows.PROCESS_VM_READ|windows.PROCESS_QUERY_INFORMATION, false, i.PID)
|
|
||||||
if err != nil {
|
|
||||||
return "", fmt.Errorf("%w: %v", ErrOpenProcess, err)
|
|
||||||
}
|
|
||||||
defer windows.CloseHandle(handle)
|
|
||||||
|
|
||||||
// Create context to control all goroutines
|
|
||||||
ctx, cancel := context.WithCancel(context.Background())
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
// Create channels for memory data and results
|
|
||||||
memoryChannel := make(chan []byte, 100)
|
|
||||||
resultChannel := make(chan string, 1)
|
|
||||||
|
|
||||||
// Determine number of worker goroutines
|
|
||||||
workerCount := runtime.NumCPU()
|
|
||||||
if workerCount < 2 {
|
|
||||||
workerCount = 2
|
|
||||||
}
|
|
||||||
if workerCount > MaxWorkers {
|
|
||||||
workerCount = MaxWorkers
|
|
||||||
}
|
|
||||||
log.Debug("Starting ", workerCount, " workers for V4 key search")
|
|
||||||
|
|
||||||
// Start consumer goroutines
|
|
||||||
var workerWaitGroup sync.WaitGroup
|
|
||||||
workerWaitGroup.Add(workerCount)
|
|
||||||
for index := 0; index < workerCount; index++ {
|
|
||||||
go func() {
|
|
||||||
defer workerWaitGroup.Done()
|
|
||||||
workerV4(ctx, handle, decryptor, memoryChannel, resultChannel)
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
|
|
||||||
// Start producer goroutine
|
|
||||||
var producerWaitGroup sync.WaitGroup
|
|
||||||
producerWaitGroup.Add(1)
|
|
||||||
go func() {
|
|
||||||
defer producerWaitGroup.Done()
|
|
||||||
defer close(memoryChannel) // Close channel when producer is done
|
|
||||||
err := i.findMemoryV4(ctx, handle, memoryChannel)
|
|
||||||
if err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for producer and consumers to complete
|
|
||||||
go func() {
|
|
||||||
producerWaitGroup.Wait()
|
|
||||||
workerWaitGroup.Wait()
|
|
||||||
close(resultChannel)
|
|
||||||
}()
|
|
||||||
|
|
||||||
// Wait for result
|
|
||||||
result, ok := <-resultChannel
|
|
||||||
if ok && result != "" {
|
|
||||||
i.Key = result
|
|
||||||
return result, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
return "", ErrNoValidKey
|
|
||||||
}
|
|
||||||
|
|
||||||
// findMemoryV3 searches for writable memory regions in WeChatWin.dll for V3 version
|
|
||||||
func (i *Info) findMemoryV3(ctx context.Context, handle windows.Handle, memoryChannel chan<- []byte) error {
|
|
||||||
// Find WeChatWin.dll module
|
|
||||||
module, isFound := FindModule(i.PID, V3ModuleName)
|
|
||||||
if !isFound {
|
|
||||||
return ErrFindWeChatDLL
|
|
||||||
}
|
|
||||||
log.Debug("Found WeChatWin.dll module at base address: 0x", fmt.Sprintf("%X", module.ModBaseAddr))
|
|
||||||
|
|
||||||
// Read writable memory regions
|
|
||||||
baseAddr := uintptr(module.ModBaseAddr)
|
|
||||||
endAddr := baseAddr + uintptr(module.ModBaseSize)
|
|
||||||
currentAddr := baseAddr
|
|
||||||
|
|
||||||
for currentAddr < endAddr {
|
|
||||||
var mbi windows.MemoryBasicInformation
|
|
||||||
err := windows.VirtualQueryEx(handle, currentAddr, &mbi, unsafe.Sizeof(mbi))
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip small memory regions
|
|
||||||
if mbi.RegionSize < 100*1024 {
|
|
||||||
currentAddr += uintptr(mbi.RegionSize)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if memory region is writable
|
|
||||||
isWritable := (mbi.Protect & (windows.PAGE_READWRITE | windows.PAGE_WRITECOPY | windows.PAGE_EXECUTE_READWRITE | windows.PAGE_EXECUTE_WRITECOPY)) > 0
|
|
||||||
if isWritable && uint32(mbi.State) == windows.MEM_COMMIT {
|
|
||||||
// Calculate region size, ensure it doesn't exceed DLL bounds
|
|
||||||
regionSize := uintptr(mbi.RegionSize)
|
|
||||||
if currentAddr+regionSize > endAddr {
|
|
||||||
regionSize = endAddr - currentAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read writable memory region
|
|
||||||
memory := make([]byte, regionSize)
|
|
||||||
if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
|
|
||||||
select {
|
|
||||||
case memoryChannel <- memory:
|
|
||||||
log.Debug("Sent memory region for analysis, size: ", regionSize, " bytes")
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to next memory region
|
|
||||||
currentAddr = uintptr(mbi.BaseAddress) + uintptr(mbi.RegionSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// workerV3 processes memory regions to find V3 version key
|
|
||||||
func workerV3(ctx context.Context, handle windows.Handle, decryptor *Decryptor, is64Bit bool, memoryChannel <-chan []byte, resultChannel chan<- string) {
|
|
||||||
|
|
||||||
// Define search pattern
|
|
||||||
keyPattern := []byte{0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
|
||||||
ptrSize := 8
|
|
||||||
littleEndianFunc := binary.LittleEndian.Uint64
|
|
||||||
|
|
||||||
// Adjust for 32-bit process
|
|
||||||
if !is64Bit {
|
|
||||||
keyPattern = keyPattern[:4]
|
|
||||||
ptrSize = 4
|
|
||||||
littleEndianFunc = func(b []byte) uint64 { return uint64(binary.LittleEndian.Uint32(b)) }
|
|
||||||
}
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case memory, ok := <-memoryChannel:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
index := len(memory)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return // Exit if result found
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find pattern from end to beginning
|
|
||||||
index = bytes.LastIndex(memory[:index], keyPattern)
|
|
||||||
if index == -1 || index-ptrSize < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and validate pointer value
|
|
||||||
ptrValue := littleEndianFunc(memory[index-ptrSize : index])
|
|
||||||
if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
|
|
||||||
if key := validateKey(handle, decryptor, ptrValue); key != "" {
|
|
||||||
select {
|
|
||||||
case resultChannel <- key:
|
|
||||||
log.Debug("Valid key found for V3 database")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
index -= 1 // Continue searching from previous position
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// findMemoryV4 searches for writable memory regions for V4 version
|
|
||||||
func (i *Info) findMemoryV4(ctx context.Context, handle windows.Handle, memoryChannel chan<- []byte) error {
|
|
||||||
// Define search range
|
|
||||||
minAddr := uintptr(0x10000) // Process space usually starts from 0x10000
|
|
||||||
maxAddr := uintptr(0x7FFFFFFF) // 32-bit process space limit
|
|
||||||
|
|
||||||
if runtime.GOARCH == "amd64" {
|
|
||||||
maxAddr = uintptr(0x7FFFFFFFFFFF) // 64-bit process space limit
|
|
||||||
}
|
|
||||||
log.Debug("Scanning memory regions from 0x", fmt.Sprintf("%X", minAddr), " to 0x", fmt.Sprintf("%X", maxAddr))
|
|
||||||
|
|
||||||
currentAddr := minAddr
|
|
||||||
|
|
||||||
for currentAddr < maxAddr {
|
|
||||||
var memInfo windows.MemoryBasicInformation
|
|
||||||
err := windows.VirtualQueryEx(handle, currentAddr, &memInfo, unsafe.Sizeof(memInfo))
|
|
||||||
if err != nil {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Skip small memory regions
|
|
||||||
if memInfo.RegionSize < 1024*1024 {
|
|
||||||
currentAddr += uintptr(memInfo.RegionSize)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if memory region is readable and private
|
|
||||||
if memInfo.State == windows.MEM_COMMIT && (memInfo.Protect&windows.PAGE_READWRITE) != 0 && memInfo.Type == MEM_PRIVATE {
|
|
||||||
// Calculate region size, ensure it doesn't exceed limit
|
|
||||||
regionSize := uintptr(memInfo.RegionSize)
|
|
||||||
if currentAddr+regionSize > maxAddr {
|
|
||||||
regionSize = maxAddr - currentAddr
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read memory region
|
|
||||||
memory := make([]byte, regionSize)
|
|
||||||
if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
|
|
||||||
select {
|
|
||||||
case memoryChannel <- memory:
|
|
||||||
log.Debug("Sent memory region for analysis, size: ", regionSize, " bytes")
|
|
||||||
case <-ctx.Done():
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Move to next memory region
|
|
||||||
currentAddr = uintptr(memInfo.BaseAddress) + uintptr(memInfo.RegionSize)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// workerV4 processes memory regions to find V4 version key
|
|
||||||
func workerV4(ctx context.Context, handle windows.Handle, decryptor *Decryptor, memoryChannel <-chan []byte, resultChannel chan<- string) {
|
|
||||||
|
|
||||||
// Define search pattern for V4
|
|
||||||
keyPattern := []byte{
|
|
||||||
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
0x2F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
|
||||||
}
|
|
||||||
ptrSize := 8
|
|
||||||
littleEndianFunc := binary.LittleEndian.Uint64
|
|
||||||
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return
|
|
||||||
case memory, ok := <-memoryChannel:
|
|
||||||
if !ok {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
index := len(memory)
|
|
||||||
for {
|
|
||||||
select {
|
|
||||||
case <-ctx.Done():
|
|
||||||
return // Exit if result found
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
|
|
||||||
// Find pattern from end to beginning
|
|
||||||
index = bytes.LastIndex(memory[:index], keyPattern)
|
|
||||||
if index == -1 || index-ptrSize < 0 {
|
|
||||||
break
|
|
||||||
}
|
|
||||||
|
|
||||||
// Extract and validate pointer value
|
|
||||||
ptrValue := littleEndianFunc(memory[index-ptrSize : index])
|
|
||||||
if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
|
|
||||||
if key := validateKey(handle, decryptor, ptrValue); key != "" {
|
|
||||||
select {
|
|
||||||
case resultChannel <- key:
|
|
||||||
log.Debug("Valid key found for V4 database")
|
|
||||||
default:
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
index -= 1 // Continue searching from previous position
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// validateKey validates a single key candidate
|
|
||||||
func validateKey(handle windows.Handle, decryptor *Decryptor, addr uint64) string {
|
|
||||||
keyData := make([]byte, 0x20) // 32-byte key
|
|
||||||
if err := windows.ReadProcessMemory(handle, uintptr(addr), &keyData[0], uintptr(len(keyData)), nil); err != nil {
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate key against database header
|
|
||||||
if decryptor.Validate(keyData) {
|
|
||||||
return hex.EncodeToString(keyData)
|
|
||||||
}
|
|
||||||
|
|
||||||
return ""
|
|
||||||
}
|
|
||||||
|
|
||||||
// FindModule searches for a specified module in the process
|
|
||||||
// Used to find WeChatWin.dll module for V3 version
|
|
||||||
func FindModule(pid uint32, name string) (module windows.ModuleEntry32, isFound bool) {
|
|
||||||
// Create module snapshot
|
|
||||||
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPMODULE|windows.TH32CS_SNAPMODULE32, pid)
|
|
||||||
if err != nil {
|
|
||||||
log.Debug("Failed to create module snapshot: ", err)
|
|
||||||
return module, false
|
|
||||||
}
|
|
||||||
defer windows.CloseHandle(snapshot)
|
|
||||||
|
|
||||||
// Initialize module entry structure
|
|
||||||
module.Size = uint32(windows.SizeofModuleEntry32)
|
|
||||||
|
|
||||||
// Get the first module
|
|
||||||
if err := windows.Module32First(snapshot, &module); err != nil {
|
|
||||||
log.Debug("Failed to get first module: ", err)
|
|
||||||
return module, false
|
|
||||||
}
|
|
||||||
|
|
||||||
// Iterate through all modules to find WeChatWin.dll
|
|
||||||
for ; err == nil; err = windows.Module32Next(snapshot, &module) {
|
|
||||||
if windows.UTF16ToString(module.Module[:]) == name {
|
|
||||||
return module, true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return module, false
|
|
||||||
}
|
|
||||||
121
internal/wechat/key/darwin/glance/glance.go
Normal file
121
internal/wechat/key/darwin/glance/glance.go
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FIXME 按照 region 读取效率较低,512MB 内存读取耗时约 18s
|
||||||
|
|
||||||
|
type Glance struct {
|
||||||
|
PID uint32
|
||||||
|
MemRegions []MemRegion
|
||||||
|
pipePath string
|
||||||
|
data []byte
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewGlance(pid uint32) *Glance {
|
||||||
|
return &Glance{
|
||||||
|
PID: pid,
|
||||||
|
pipePath: filepath.Join(os.TempDir(), fmt.Sprintf("chatlog_pipe_%d", time.Now().UnixNano())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (g *Glance) Read() ([]byte, error) {
|
||||||
|
if g.data != nil {
|
||||||
|
return g.data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
regions, err := GetVmmap(g.PID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
g.MemRegions = MemRegionsFilter(regions)
|
||||||
|
|
||||||
|
if len(g.MemRegions) == 0 {
|
||||||
|
return nil, errors.ErrNoMemoryRegionsFound
|
||||||
|
}
|
||||||
|
|
||||||
|
region := g.MemRegions[0]
|
||||||
|
|
||||||
|
// 1. Create pipe file
|
||||||
|
if err := exec.Command("mkfifo", g.pipePath).Run(); err != nil {
|
||||||
|
return nil, errors.CreatePipeFileFailed(err)
|
||||||
|
}
|
||||||
|
defer os.Remove(g.pipePath)
|
||||||
|
|
||||||
|
// Start a goroutine to read from the pipe
|
||||||
|
dataCh := make(chan []byte, 1)
|
||||||
|
errCh := make(chan error, 1)
|
||||||
|
go func() {
|
||||||
|
// Open pipe for reading
|
||||||
|
file, err := os.OpenFile(g.pipePath, os.O_RDONLY, 0600)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- errors.OpenPipeFileFailed(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
// Read all data from pipe
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
errCh <- errors.ReadPipeFileFailed(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dataCh <- data
|
||||||
|
}()
|
||||||
|
|
||||||
|
// 2 & 3. Execute lldb command to read memory directly with all parameters
|
||||||
|
size := region.End - region.Start
|
||||||
|
lldbCmd := fmt.Sprintf("lldb -p %d -o \"memory read --binary --force --outfile %s --count %d 0x%x\" -o \"quit\"",
|
||||||
|
g.PID, g.pipePath, size, region.Start)
|
||||||
|
|
||||||
|
cmd := exec.Command("bash", "-c", lldbCmd)
|
||||||
|
|
||||||
|
// Set up stdout pipe for monitoring (optional)
|
||||||
|
stdout, err := cmd.StdoutPipe()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start the command
|
||||||
|
if err := cmd.Start(); err != nil {
|
||||||
|
return nil, errors.RunCmdFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Monitor lldb output (optional)
|
||||||
|
go func() {
|
||||||
|
scanner := bufio.NewScanner(stdout)
|
||||||
|
for scanner.Scan() {
|
||||||
|
// Uncomment for debugging:
|
||||||
|
// fmt.Println(scanner.Text())
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for data with timeout
|
||||||
|
select {
|
||||||
|
case data := <-dataCh:
|
||||||
|
g.data = data
|
||||||
|
case err := <-errCh:
|
||||||
|
return nil, errors.ReadMemoryFailed(err)
|
||||||
|
case <-time.After(30 * time.Second):
|
||||||
|
cmd.Process.Kill()
|
||||||
|
return nil, errors.ErrReadMemoryTimeout
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for the command to finish
|
||||||
|
if err := cmd.Wait(); err != nil {
|
||||||
|
// We already have the data, so just log the error
|
||||||
|
log.Err(err).Msg("lldb process exited with error")
|
||||||
|
}
|
||||||
|
|
||||||
|
return g.data, nil
|
||||||
|
}
|
||||||
37
internal/wechat/key/darwin/glance/sip.go
Normal file
37
internal/wechat/key/darwin/glance/sip.go
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// IsSIPDisabled checks if System Integrity Protection (SIP) is disabled on macOS.
|
||||||
|
// Returns true if SIP is disabled, false if it's enabled or if the status cannot be determined.
|
||||||
|
func IsSIPDisabled() bool {
|
||||||
|
// Run the csrutil status command to check SIP status
|
||||||
|
cmd := exec.Command("csrutil", "status")
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
// If there's an error running the command, assume SIP is enabled
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Convert output to string and check if SIP is disabled
|
||||||
|
outputStr := strings.ToLower(string(output))
|
||||||
|
|
||||||
|
// $ csrutil status
|
||||||
|
// System Integrity Protection status: disabled.
|
||||||
|
|
||||||
|
// If the output contains "disabled", SIP is disabled
|
||||||
|
if strings.Contains(outputStr, "system integrity protection status: disabled") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for partial SIP disabling - some configurations might have specific protections disabled
|
||||||
|
if strings.Contains(outputStr, "disabled") && strings.Contains(outputStr, "debugging") {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
// By default, assume SIP is enabled
|
||||||
|
return false
|
||||||
|
}
|
||||||
160
internal/wechat/key/darwin/glance/vmmap.go
Normal file
160
internal/wechat/key/darwin/glance/vmmap.go
Normal file
@@ -0,0 +1,160 @@
|
|||||||
|
package glance
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bufio"
|
||||||
|
"fmt"
|
||||||
|
"os/exec"
|
||||||
|
"regexp"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
FilterRegionType = "MALLOC_NANO"
|
||||||
|
FilterSHRMOD = "SM=PRV"
|
||||||
|
CommandVmmap = "vmmap"
|
||||||
|
)
|
||||||
|
|
||||||
|
type MemRegion struct {
|
||||||
|
RegionType string
|
||||||
|
Start uint64
|
||||||
|
End uint64
|
||||||
|
VSize uint64 // Size in bytes
|
||||||
|
RSDNT uint64 // Resident memory size in bytes (new field)
|
||||||
|
SHRMOD string
|
||||||
|
Permissions string
|
||||||
|
RegionDetail string
|
||||||
|
}
|
||||||
|
|
||||||
|
func GetVmmap(pid uint32) ([]MemRegion, error) {
|
||||||
|
// Execute vmmap command
|
||||||
|
cmd := exec.Command(CommandVmmap, "-wide", fmt.Sprintf("%d", pid))
|
||||||
|
output, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.RunCmdFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the output using the existing LoadVmmap function
|
||||||
|
return LoadVmmap(string(output))
|
||||||
|
}
|
||||||
|
|
||||||
|
func LoadVmmap(output string) ([]MemRegion, error) {
|
||||||
|
var regions []MemRegion
|
||||||
|
|
||||||
|
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||||
|
|
||||||
|
// Skip lines until we find the header
|
||||||
|
foundHeader := false
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if strings.HasPrefix(line, "==== Writable regions for") {
|
||||||
|
foundHeader = true
|
||||||
|
// Skip the column headers line
|
||||||
|
scanner.Scan()
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !foundHeader {
|
||||||
|
return nil, nil // No vmmap data found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular expression to parse the vmmap output lines
|
||||||
|
// Format: REGION TYPE START - END [ VSIZE RSDNT DIRTY SWAP] PRT/MAX SHRMOD PURGE REGION DETAIL
|
||||||
|
// Updated regex to capture RSDNT value (second value in brackets)
|
||||||
|
re := regexp.MustCompile(`^(\S+)\s+([0-9a-f]+)-([0-9a-f]+)\s+\[\s*(\S+)\s+(\S+)(?:\s+\S+){2}\]\s+(\S+)\s+(\S+)(?:\s+\S+)?\s+(.*)$`)
|
||||||
|
|
||||||
|
// Parse each line
|
||||||
|
for scanner.Scan() {
|
||||||
|
line := scanner.Text()
|
||||||
|
if line == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
matches := re.FindStringSubmatch(line)
|
||||||
|
if len(matches) >= 9 { // Updated to check for at least 9 matches
|
||||||
|
|
||||||
|
// Parse start and end addresses
|
||||||
|
start, _ := strconv.ParseUint(matches[2], 16, 64)
|
||||||
|
end, _ := strconv.ParseUint(matches[3], 16, 64)
|
||||||
|
|
||||||
|
// Parse VSize as numeric value
|
||||||
|
vsize := parseSize(matches[4])
|
||||||
|
|
||||||
|
// Parse RSDNT as numeric value (new)
|
||||||
|
rsdnt := parseSize(matches[5])
|
||||||
|
|
||||||
|
region := MemRegion{
|
||||||
|
RegionType: strings.TrimSpace(matches[1]),
|
||||||
|
Start: start,
|
||||||
|
End: end,
|
||||||
|
VSize: vsize,
|
||||||
|
RSDNT: rsdnt, // Add the new RSDNT field
|
||||||
|
Permissions: matches[6], // Shifted index
|
||||||
|
SHRMOD: matches[7], // Shifted index
|
||||||
|
RegionDetail: strings.TrimSpace(matches[8]), // Shifted index
|
||||||
|
}
|
||||||
|
|
||||||
|
regions = append(regions, region)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return regions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func MemRegionsFilter(regions []MemRegion) []MemRegion {
|
||||||
|
var filteredRegions []MemRegion
|
||||||
|
for _, region := range regions {
|
||||||
|
if region.RegionType == FilterRegionType {
|
||||||
|
filteredRegions = append(filteredRegions, region)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return filteredRegions
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseSize converts size strings like "5616K" or "128.0M" to bytes (uint64)
|
||||||
|
func parseSize(sizeStr string) uint64 {
|
||||||
|
// Remove any whitespace
|
||||||
|
sizeStr = strings.TrimSpace(sizeStr)
|
||||||
|
|
||||||
|
// Define multipliers for different units
|
||||||
|
multipliers := map[string]uint64{
|
||||||
|
"B": 1,
|
||||||
|
"K": 1024,
|
||||||
|
"KB": 1024,
|
||||||
|
"M": 1024 * 1024,
|
||||||
|
"MB": 1024 * 1024,
|
||||||
|
"G": 1024 * 1024 * 1024,
|
||||||
|
"GB": 1024 * 1024 * 1024,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regular expression to match numbers with optional decimal point and unit
|
||||||
|
// This will match formats like: "5616K", "128.0M", "1.5G", etc.
|
||||||
|
re := regexp.MustCompile(`^(\d+(?:\.\d+)?)([KMGB]+)?$`)
|
||||||
|
matches := re.FindStringSubmatch(sizeStr)
|
||||||
|
|
||||||
|
if len(matches) < 2 {
|
||||||
|
return 0 // No match found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse the numeric part (which may include a decimal point)
|
||||||
|
numStr := matches[1]
|
||||||
|
numVal, err := strconv.ParseFloat(numStr, 64)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine the multiplier based on the unit
|
||||||
|
multiplier := uint64(1) // Default if no unit specified
|
||||||
|
if len(matches) >= 3 && matches[2] != "" {
|
||||||
|
unit := matches[2]
|
||||||
|
if m, ok := multipliers[unit]; ok {
|
||||||
|
multiplier = m
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate final size in bytes (rounding to nearest integer)
|
||||||
|
return uint64(numVal*float64(multiplier) + 0.5)
|
||||||
|
}
|
||||||
264
internal/wechat/key/darwin/v3.go
Normal file
264
internal/wechat/key/darwin/v3.go
Normal file
@@ -0,0 +1,264 @@
|
|||||||
|
package darwin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/key/darwin/glance"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxWorkersV3 = 8
|
||||||
|
)
|
||||||
|
|
||||||
|
var V3KeyPatterns = []KeyPatternInfo{
|
||||||
|
{
|
||||||
|
Pattern: []byte{0x72, 0x74, 0x72, 0x65, 0x65, 0x5f, 0x69, 0x33, 0x32},
|
||||||
|
Offsets: []int{24},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type V3Extractor struct {
|
||||||
|
validator *decrypt.Validator
|
||||||
|
keyPatterns []KeyPatternInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewV3Extractor() *V3Extractor {
|
||||||
|
return &V3Extractor{
|
||||||
|
keyPatterns: V3KeyPatterns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||||
|
if proc.Status == model.StatusOffline {
|
||||||
|
return "", errors.ErrWeChatOffline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SIP is disabled, as it's required for memory reading on macOS
|
||||||
|
if !glance.IsSIPDisabled() {
|
||||||
|
return "", errors.ErrSIPEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.validator == nil {
|
||||||
|
return "", errors.ErrValidatorNotSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context to control all goroutines
|
||||||
|
searchCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create channels for memory data and results
|
||||||
|
memoryChannel := make(chan []byte, 100)
|
||||||
|
resultChannel := make(chan string, 1)
|
||||||
|
|
||||||
|
// Determine number of worker goroutines
|
||||||
|
workerCount := runtime.NumCPU()
|
||||||
|
if workerCount < 2 {
|
||||||
|
workerCount = 2
|
||||||
|
}
|
||||||
|
if workerCount > MaxWorkersV3 {
|
||||||
|
workerCount = MaxWorkersV3
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Starting %d workers for V3 key search", workerCount)
|
||||||
|
|
||||||
|
// Start consumer goroutines
|
||||||
|
var workerWaitGroup sync.WaitGroup
|
||||||
|
workerWaitGroup.Add(workerCount)
|
||||||
|
for index := 0; index < workerCount; index++ {
|
||||||
|
go func() {
|
||||||
|
defer workerWaitGroup.Done()
|
||||||
|
e.worker(searchCtx, memoryChannel, resultChannel)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start producer goroutine
|
||||||
|
var producerWaitGroup sync.WaitGroup
|
||||||
|
producerWaitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer producerWaitGroup.Done()
|
||||||
|
defer close(memoryChannel) // Close channel when producer is done
|
||||||
|
err := e.findMemory(searchCtx, uint32(proc.PID), memoryChannel)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to read memory")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for producer and consumers to complete
|
||||||
|
go func() {
|
||||||
|
producerWaitGroup.Wait()
|
||||||
|
workerWaitGroup.Wait()
|
||||||
|
close(resultChannel)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for result
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case result, ok := <-resultChannel:
|
||||||
|
if ok && result != "" {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.ErrNoValidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMemory searches for memory regions using Glance
|
||||||
|
func (e *V3Extractor) findMemory(ctx context.Context, pid uint32, memoryChannel chan<- []byte) error {
|
||||||
|
// Initialize a Glance instance to read process memory
|
||||||
|
g := glance.NewGlance(pid)
|
||||||
|
|
||||||
|
// Read memory data
|
||||||
|
memory, err := g.Read()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSize := len(memory)
|
||||||
|
log.Debug().Msgf("Read memory region, size: %d bytes", totalSize)
|
||||||
|
|
||||||
|
// If memory is small enough, process it as a single chunk
|
||||||
|
if totalSize <= MinChunkSize {
|
||||||
|
select {
|
||||||
|
case memoryChannel <- memory:
|
||||||
|
log.Debug().Msg("Memory sent as a single chunk for analysis")
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCount := MaxWorkers * ChunkMultiplier
|
||||||
|
|
||||||
|
// Calculate chunk size based on fixed chunk count
|
||||||
|
chunkSize := totalSize / chunkCount
|
||||||
|
if chunkSize < MinChunkSize {
|
||||||
|
// Reduce number of chunks if each would be too small
|
||||||
|
chunkCount = totalSize / MinChunkSize
|
||||||
|
if chunkCount == 0 {
|
||||||
|
chunkCount = 1
|
||||||
|
}
|
||||||
|
chunkSize = totalSize / chunkCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process memory in chunks from end to beginning
|
||||||
|
for i := chunkCount - 1; i >= 0; i-- {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
// Calculate start and end positions for this chunk
|
||||||
|
start := i * chunkSize
|
||||||
|
end := (i + 1) * chunkSize
|
||||||
|
|
||||||
|
// Ensure the last chunk includes all remaining memory
|
||||||
|
if i == chunkCount-1 {
|
||||||
|
end = totalSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add overlap area to catch patterns at chunk boundaries
|
||||||
|
if i > 0 {
|
||||||
|
start -= ChunkOverlapBytes
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := memory[start:end]
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Int("chunk_index", i+1).
|
||||||
|
Int("total_chunks", chunkCount).
|
||||||
|
Int("chunk_size", len(chunk)).
|
||||||
|
Int("start_offset", start).
|
||||||
|
Int("end_offset", end).
|
||||||
|
Msg("Processing memory chunk")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case memoryChannel <- chunk:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker processes memory regions to find V3 version key
|
||||||
|
func (e *V3Extractor) worker(ctx context.Context, memoryChannel <-chan []byte, resultChannel chan<- string) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case memory, ok := <-memoryChannel:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if key, ok := e.SearchKey(ctx, memory); ok {
|
||||||
|
select {
|
||||||
|
case resultChannel <- key:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V3Extractor) SearchKey(ctx context.Context, memory []byte) (string, bool) {
|
||||||
|
for _, keyPattern := range e.keyPatterns {
|
||||||
|
index := len(memory)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", false
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find pattern from end to beginning
|
||||||
|
index = bytes.LastIndex(memory[:index], keyPattern.Pattern)
|
||||||
|
if index == -1 {
|
||||||
|
break // No more matches found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try each offset for this pattern
|
||||||
|
for _, offset := range keyPattern.Offsets {
|
||||||
|
// Check if we have enough space for the key
|
||||||
|
keyOffset := index + offset
|
||||||
|
if keyOffset < 0 || keyOffset+32 > len(memory) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the key data, which is at the offset position and 32 bytes long
|
||||||
|
keyData := memory[keyOffset : keyOffset+32]
|
||||||
|
|
||||||
|
// Validate key against database header
|
||||||
|
if e.validator.Validate(keyData) {
|
||||||
|
log.Debug().
|
||||||
|
Str("pattern", hex.EncodeToString(keyPattern.Pattern)).
|
||||||
|
Int("offset", offset).
|
||||||
|
Str("key", hex.EncodeToString(keyData)).
|
||||||
|
Msg("Key found")
|
||||||
|
return hex.EncodeToString(keyData), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V3Extractor) SetValidate(validator *decrypt.Validator) {
|
||||||
|
e.validator = validator
|
||||||
|
}
|
||||||
281
internal/wechat/key/darwin/v4.go
Normal file
281
internal/wechat/key/darwin/v4.go
Normal file
@@ -0,0 +1,281 @@
|
|||||||
|
package darwin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/hex"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/key/darwin/glance"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MaxWorkers = 8
|
||||||
|
MinChunkSize = 1 * 1024 * 1024 // 1MB
|
||||||
|
ChunkOverlapBytes = 1024 // Greater than all offsets
|
||||||
|
ChunkMultiplier = 2 // Number of chunks = MaxWorkers * ChunkMultiplier
|
||||||
|
)
|
||||||
|
|
||||||
|
var V4KeyPatterns = []KeyPatternInfo{
|
||||||
|
{
|
||||||
|
Pattern: []byte{0x20, 0x66, 0x74, 0x73, 0x35, 0x28, 0x25, 0x00},
|
||||||
|
Offsets: []int{16, -80, 64},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type V4Extractor struct {
|
||||||
|
validator *decrypt.Validator
|
||||||
|
keyPatterns []KeyPatternInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewV4Extractor() *V4Extractor {
|
||||||
|
return &V4Extractor{
|
||||||
|
keyPatterns: V4KeyPatterns,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||||
|
if proc.Status == model.StatusOffline {
|
||||||
|
return "", errors.ErrWeChatOffline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if SIP is disabled, as it's required for memory reading on macOS
|
||||||
|
if !glance.IsSIPDisabled() {
|
||||||
|
return "", errors.ErrSIPEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
if e.validator == nil {
|
||||||
|
return "", errors.ErrValidatorNotSet
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context to control all goroutines
|
||||||
|
searchCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create channels for memory data and results
|
||||||
|
memoryChannel := make(chan []byte, 100)
|
||||||
|
resultChannel := make(chan string, 1)
|
||||||
|
|
||||||
|
// Determine number of worker goroutines
|
||||||
|
workerCount := runtime.NumCPU()
|
||||||
|
if workerCount < 2 {
|
||||||
|
workerCount = 2
|
||||||
|
}
|
||||||
|
if workerCount > MaxWorkers {
|
||||||
|
workerCount = MaxWorkers
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Starting %d workers for V4 key search", workerCount)
|
||||||
|
|
||||||
|
// Start consumer goroutines
|
||||||
|
var workerWaitGroup sync.WaitGroup
|
||||||
|
workerWaitGroup.Add(workerCount)
|
||||||
|
for index := 0; index < workerCount; index++ {
|
||||||
|
go func() {
|
||||||
|
defer workerWaitGroup.Done()
|
||||||
|
e.worker(searchCtx, memoryChannel, resultChannel)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start producer goroutine
|
||||||
|
var producerWaitGroup sync.WaitGroup
|
||||||
|
producerWaitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer producerWaitGroup.Done()
|
||||||
|
defer close(memoryChannel) // Close channel when producer is done
|
||||||
|
err := e.findMemory(searchCtx, uint32(proc.PID), memoryChannel)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to read memory")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for producer and consumers to complete
|
||||||
|
go func() {
|
||||||
|
producerWaitGroup.Wait()
|
||||||
|
workerWaitGroup.Wait()
|
||||||
|
close(resultChannel)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for result
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case result, ok := <-resultChannel:
|
||||||
|
if ok && result != "" {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.ErrNoValidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMemory searches for memory regions using Glance
|
||||||
|
func (e *V4Extractor) findMemory(ctx context.Context, pid uint32, memoryChannel chan<- []byte) error {
|
||||||
|
// Initialize a Glance instance to read process memory
|
||||||
|
g := glance.NewGlance(pid)
|
||||||
|
|
||||||
|
// Read memory data
|
||||||
|
memory, err := g.Read()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
totalSize := len(memory)
|
||||||
|
log.Debug().Msgf("Read memory region, size: %d bytes", totalSize)
|
||||||
|
|
||||||
|
// If memory is small enough, process it as a single chunk
|
||||||
|
if totalSize <= MinChunkSize {
|
||||||
|
select {
|
||||||
|
case memoryChannel <- memory:
|
||||||
|
log.Debug().Msg("Memory sent as a single chunk for analysis")
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
chunkCount := MaxWorkers * ChunkMultiplier
|
||||||
|
|
||||||
|
// Calculate chunk size based on fixed chunk count
|
||||||
|
chunkSize := totalSize / chunkCount
|
||||||
|
if chunkSize < MinChunkSize {
|
||||||
|
// Reduce number of chunks if each would be too small
|
||||||
|
chunkCount = totalSize / MinChunkSize
|
||||||
|
if chunkCount == 0 {
|
||||||
|
chunkCount = 1
|
||||||
|
}
|
||||||
|
chunkSize = totalSize / chunkCount
|
||||||
|
}
|
||||||
|
|
||||||
|
// Process memory in chunks from end to beginning
|
||||||
|
for i := chunkCount - 1; i >= 0; i-- {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
default:
|
||||||
|
// Calculate start and end positions for this chunk
|
||||||
|
start := i * chunkSize
|
||||||
|
end := (i + 1) * chunkSize
|
||||||
|
|
||||||
|
// Ensure the last chunk includes all remaining memory
|
||||||
|
if i == chunkCount-1 {
|
||||||
|
end = totalSize
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add overlap area to catch patterns at chunk boundaries
|
||||||
|
if i > 0 {
|
||||||
|
start -= ChunkOverlapBytes
|
||||||
|
if start < 0 {
|
||||||
|
start = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
chunk := memory[start:end]
|
||||||
|
|
||||||
|
log.Debug().
|
||||||
|
Int("chunk_index", i+1).
|
||||||
|
Int("total_chunks", chunkCount).
|
||||||
|
Int("chunk_size", len(chunk)).
|
||||||
|
Int("start_offset", start).
|
||||||
|
Int("end_offset", end).
|
||||||
|
Msg("Processing memory chunk")
|
||||||
|
|
||||||
|
select {
|
||||||
|
case memoryChannel <- chunk:
|
||||||
|
case <-ctx.Done():
|
||||||
|
return ctx.Err()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// worker processes memory regions to find V4 version key
|
||||||
|
func (e *V4Extractor) worker(ctx context.Context, memoryChannel <-chan []byte, resultChannel chan<- string) {
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case memory, ok := <-memoryChannel:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if key, ok := e.SearchKey(ctx, memory); ok {
|
||||||
|
select {
|
||||||
|
case resultChannel <- key:
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V4Extractor) SearchKey(ctx context.Context, memory []byte) (string, bool) {
|
||||||
|
for _, keyPattern := range e.keyPatterns {
|
||||||
|
index := len(memory)
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", false
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find pattern from end to beginning
|
||||||
|
index = bytes.LastIndex(memory[:index], keyPattern.Pattern)
|
||||||
|
if index == -1 {
|
||||||
|
break // No more matches found
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try each offset for this pattern
|
||||||
|
for _, offset := range keyPattern.Offsets {
|
||||||
|
// Check if we have enough space for the key
|
||||||
|
keyOffset := index + offset
|
||||||
|
if keyOffset < 0 || keyOffset+32 > len(memory) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract the key data, which is at the offset position and 32 bytes long
|
||||||
|
keyData := memory[keyOffset : keyOffset+32]
|
||||||
|
|
||||||
|
// Validate key against database header
|
||||||
|
if keyData, ok := e.validate(ctx, keyData); ok {
|
||||||
|
log.Debug().
|
||||||
|
Str("pattern", hex.EncodeToString(keyPattern.Pattern)).
|
||||||
|
Int("offset", offset).
|
||||||
|
Str("key", hex.EncodeToString(keyData)).
|
||||||
|
Msg("Key found")
|
||||||
|
return hex.EncodeToString(keyData), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
index -= 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V4Extractor) validate(ctx context.Context, keyDate []byte) ([]byte, bool) {
|
||||||
|
if e.validator.Validate(keyDate) {
|
||||||
|
return keyDate, true
|
||||||
|
}
|
||||||
|
// Try to find a valid key by ***
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V4Extractor) SetValidate(validator *decrypt.Validator) {
|
||||||
|
e.validator = validator
|
||||||
|
}
|
||||||
|
|
||||||
|
type KeyPatternInfo struct {
|
||||||
|
Pattern []byte
|
||||||
|
Offsets []int
|
||||||
|
}
|
||||||
38
internal/wechat/key/extractor.go
Normal file
38
internal/wechat/key/extractor.go
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package key
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/key/darwin"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/key/windows"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Extractor 定义密钥提取器接口
|
||||||
|
type Extractor interface {
|
||||||
|
// Extract 从进程中提取密钥
|
||||||
|
Extract(ctx context.Context, proc *model.Process) (string, error)
|
||||||
|
|
||||||
|
// SearchKey 在内存中搜索密钥
|
||||||
|
SearchKey(ctx context.Context, memory []byte) (string, bool)
|
||||||
|
|
||||||
|
SetValidate(validator *decrypt.Validator)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewExtractor 创建适合当前平台的密钥提取器
|
||||||
|
func NewExtractor(platform string, version int) (Extractor, error) {
|
||||||
|
switch {
|
||||||
|
case platform == "windows" && version == 3:
|
||||||
|
return windows.NewV3Extractor(), nil
|
||||||
|
case platform == "windows" && version == 4:
|
||||||
|
return windows.NewV4Extractor(), nil
|
||||||
|
case platform == "darwin" && version == 3:
|
||||||
|
return darwin.NewV3Extractor(), nil
|
||||||
|
case platform == "darwin" && version == 4:
|
||||||
|
return darwin.NewV4Extractor(), nil
|
||||||
|
default:
|
||||||
|
return nil, errors.PlatformUnsupported(platform, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
24
internal/wechat/key/windows/v3.go
Normal file
24
internal/wechat/key/windows/v3.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type V3Extractor struct {
|
||||||
|
validator *decrypt.Validator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewV3Extractor() *V3Extractor {
|
||||||
|
return &V3Extractor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V3Extractor) SearchKey(ctx context.Context, memory []byte) (string, bool) {
|
||||||
|
// TODO : Implement the key search logic for V3
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V3Extractor) SetValidate(validator *decrypt.Validator) {
|
||||||
|
e.validator = validator
|
||||||
|
}
|
||||||
13
internal/wechat/key/windows/v3_others.go
Normal file
13
internal/wechat/key/windows/v3_others.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
255
internal/wechat/key/windows/v3_windows.go
Normal file
255
internal/wechat/key/windows/v3_windows.go
Normal file
@@ -0,0 +1,255 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
"github.com/sjzar/chatlog/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
V3ModuleName = "WeChatWin.dll"
|
||||||
|
MaxWorkers = 16
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *V3Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||||
|
if proc.Status == model.StatusOffline {
|
||||||
|
return "", errors.ErrWeChatOffline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open WeChat process
|
||||||
|
handle, err := windows.OpenProcess(windows.PROCESS_QUERY_INFORMATION|windows.PROCESS_VM_READ, false, proc.PID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.OpenProcessFailed(err)
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(handle)
|
||||||
|
|
||||||
|
// Check process architecture
|
||||||
|
is64Bit, err := util.Is64Bit(handle)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create context to control all goroutines
|
||||||
|
searchCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create channels for memory data and results
|
||||||
|
memoryChannel := make(chan []byte, 100)
|
||||||
|
resultChannel := make(chan string, 1)
|
||||||
|
|
||||||
|
// Determine number of worker goroutines
|
||||||
|
workerCount := runtime.NumCPU()
|
||||||
|
if workerCount < 2 {
|
||||||
|
workerCount = 2
|
||||||
|
}
|
||||||
|
if workerCount > MaxWorkers {
|
||||||
|
workerCount = MaxWorkers
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Starting %d workers for V3 key search", workerCount)
|
||||||
|
|
||||||
|
// Start consumer goroutines
|
||||||
|
var workerWaitGroup sync.WaitGroup
|
||||||
|
workerWaitGroup.Add(workerCount)
|
||||||
|
for index := 0; index < workerCount; index++ {
|
||||||
|
go func() {
|
||||||
|
defer workerWaitGroup.Done()
|
||||||
|
e.worker(searchCtx, handle, is64Bit, memoryChannel, resultChannel)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start producer goroutine
|
||||||
|
var producerWaitGroup sync.WaitGroup
|
||||||
|
producerWaitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer producerWaitGroup.Done()
|
||||||
|
defer close(memoryChannel) // Close channel when producer is done
|
||||||
|
err := e.findMemory(searchCtx, handle, proc.PID, memoryChannel)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to find memory regions")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for producer and consumers to complete
|
||||||
|
go func() {
|
||||||
|
producerWaitGroup.Wait()
|
||||||
|
workerWaitGroup.Wait()
|
||||||
|
close(resultChannel)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for result
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case result, ok := <-resultChannel:
|
||||||
|
if ok && result != "" {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.ErrNoValidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMemoryV3 searches for writable memory regions in WeChatWin.dll for V3 version
|
||||||
|
func (e *V3Extractor) findMemory(ctx context.Context, handle windows.Handle, pid uint32, memoryChannel chan<- []byte) error {
|
||||||
|
// Find WeChatWin.dll module
|
||||||
|
module, isFound := FindModule(pid, V3ModuleName)
|
||||||
|
if !isFound {
|
||||||
|
return errors.ErrWeChatDLLNotFound
|
||||||
|
}
|
||||||
|
log.Debug().Msg("Found WeChatWin.dll module at base address: 0x" + fmt.Sprintf("%X", module.ModBaseAddr))
|
||||||
|
|
||||||
|
// Read writable memory regions
|
||||||
|
baseAddr := uintptr(module.ModBaseAddr)
|
||||||
|
endAddr := baseAddr + uintptr(module.ModBaseSize)
|
||||||
|
currentAddr := baseAddr
|
||||||
|
|
||||||
|
for currentAddr < endAddr {
|
||||||
|
var mbi windows.MemoryBasicInformation
|
||||||
|
err := windows.VirtualQueryEx(handle, currentAddr, &mbi, unsafe.Sizeof(mbi))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip small memory regions
|
||||||
|
if mbi.RegionSize < 100*1024 {
|
||||||
|
currentAddr += uintptr(mbi.RegionSize)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if memory region is writable
|
||||||
|
isWritable := (mbi.Protect & (windows.PAGE_READWRITE | windows.PAGE_WRITECOPY | windows.PAGE_EXECUTE_READWRITE | windows.PAGE_EXECUTE_WRITECOPY)) > 0
|
||||||
|
if isWritable && uint32(mbi.State) == windows.MEM_COMMIT {
|
||||||
|
// Calculate region size, ensure it doesn't exceed DLL bounds
|
||||||
|
regionSize := uintptr(mbi.RegionSize)
|
||||||
|
if currentAddr+regionSize > endAddr {
|
||||||
|
regionSize = endAddr - currentAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read writable memory region
|
||||||
|
memory := make([]byte, regionSize)
|
||||||
|
if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
|
||||||
|
select {
|
||||||
|
case memoryChannel <- memory:
|
||||||
|
log.Debug().Msgf("Memory region: 0x%X - 0x%X, size: %d bytes", currentAddr, currentAddr+regionSize, regionSize)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next memory region
|
||||||
|
currentAddr = uintptr(mbi.BaseAddress) + uintptr(mbi.RegionSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// workerV3 processes memory regions to find V3 version key
|
||||||
|
func (e *V3Extractor) worker(ctx context.Context, handle windows.Handle, is64Bit bool, memoryChannel <-chan []byte, resultChannel chan<- string) {
|
||||||
|
// Define search pattern
|
||||||
|
keyPattern := []byte{0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00}
|
||||||
|
ptrSize := 8
|
||||||
|
littleEndianFunc := binary.LittleEndian.Uint64
|
||||||
|
|
||||||
|
// Adjust for 32-bit process
|
||||||
|
if !is64Bit {
|
||||||
|
keyPattern = keyPattern[:4]
|
||||||
|
ptrSize = 4
|
||||||
|
littleEndianFunc = func(b []byte) uint64 { return uint64(binary.LittleEndian.Uint32(b)) }
|
||||||
|
}
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case memory, ok := <-memoryChannel:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
index := len(memory)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return // Exit if context cancelled
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find pattern from end to beginning
|
||||||
|
index = bytes.LastIndex(memory[:index], keyPattern)
|
||||||
|
if index == -1 || index-ptrSize < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate pointer value
|
||||||
|
ptrValue := littleEndianFunc(memory[index-ptrSize : index])
|
||||||
|
if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
|
||||||
|
if key := e.validateKey(handle, ptrValue); key != "" {
|
||||||
|
select {
|
||||||
|
case resultChannel <- key:
|
||||||
|
log.Debug().Msg("Valid key found: " + key)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index -= 1 // Continue searching from previous position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateKey validates a single key candidate
|
||||||
|
func (e *V3Extractor) validateKey(handle windows.Handle, addr uint64) string {
|
||||||
|
keyData := make([]byte, 0x20) // 32-byte key
|
||||||
|
if err := windows.ReadProcessMemory(handle, uintptr(addr), &keyData[0], uintptr(len(keyData)), nil); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key against database header
|
||||||
|
if e.validator.Validate(keyData) {
|
||||||
|
return hex.EncodeToString(keyData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindModule searches for a specified module in the process
|
||||||
|
func FindModule(pid uint32, name string) (module windows.ModuleEntry32, isFound bool) {
|
||||||
|
// Create module snapshot
|
||||||
|
snapshot, err := windows.CreateToolhelp32Snapshot(windows.TH32CS_SNAPMODULE|windows.TH32CS_SNAPMODULE32, pid)
|
||||||
|
if err != nil {
|
||||||
|
log.Debug().Msgf("Failed to create module snapshot for PID %d: %v", pid, err)
|
||||||
|
return module, false
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(snapshot)
|
||||||
|
|
||||||
|
// Initialize module entry structure
|
||||||
|
module.Size = uint32(windows.SizeofModuleEntry32)
|
||||||
|
|
||||||
|
// Get the first module
|
||||||
|
if err := windows.Module32First(snapshot, &module); err != nil {
|
||||||
|
log.Debug().Msgf("Module32First failed for PID %d: %v", pid, err)
|
||||||
|
return module, false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Iterate through all modules to find WeChatWin.dll
|
||||||
|
for ; err == nil; err = windows.Module32Next(snapshot, &module) {
|
||||||
|
if windows.UTF16ToString(module.Module[:]) == name {
|
||||||
|
return module, true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return module, false
|
||||||
|
}
|
||||||
24
internal/wechat/key/windows/v4.go
Normal file
24
internal/wechat/key/windows/v4.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||||
|
)
|
||||||
|
|
||||||
|
type V4Extractor struct {
|
||||||
|
validator *decrypt.Validator
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewV4Extractor() *V4Extractor {
|
||||||
|
return &V4Extractor{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V4Extractor) SearchKey(ctx context.Context, memory []byte) (string, bool) {
|
||||||
|
// TODO : Implement the key search logic for V4
|
||||||
|
return "", false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (e *V4Extractor) SetValidate(validator *decrypt.Validator) {
|
||||||
|
e.validator = validator
|
||||||
|
}
|
||||||
13
internal/wechat/key/windows/v4_others.go
Normal file
13
internal/wechat/key/windows/v4_others.go
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
213
internal/wechat/key/windows/v4_windows.go
Normal file
213
internal/wechat/key/windows/v4_windows.go
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"context"
|
||||||
|
"encoding/binary"
|
||||||
|
"encoding/hex"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"unsafe"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"golang.org/x/sys/windows"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
MEM_PRIVATE = 0x20000
|
||||||
|
)
|
||||||
|
|
||||||
|
func (e *V4Extractor) Extract(ctx context.Context, proc *model.Process) (string, error) {
|
||||||
|
if proc.Status == model.StatusOffline {
|
||||||
|
return "", errors.ErrWeChatOffline
|
||||||
|
}
|
||||||
|
|
||||||
|
// Open process handle
|
||||||
|
handle, err := windows.OpenProcess(windows.PROCESS_VM_READ|windows.PROCESS_QUERY_INFORMATION, false, proc.PID)
|
||||||
|
if err != nil {
|
||||||
|
return "", errors.OpenProcessFailed(err)
|
||||||
|
}
|
||||||
|
defer windows.CloseHandle(handle)
|
||||||
|
|
||||||
|
// Create context to control all goroutines
|
||||||
|
searchCtx, cancel := context.WithCancel(ctx)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
|
// Create channels for memory data and results
|
||||||
|
memoryChannel := make(chan []byte, 100)
|
||||||
|
resultChannel := make(chan string, 1)
|
||||||
|
|
||||||
|
// Determine number of worker goroutines
|
||||||
|
workerCount := runtime.NumCPU()
|
||||||
|
if workerCount < 2 {
|
||||||
|
workerCount = 2
|
||||||
|
}
|
||||||
|
if workerCount > MaxWorkers {
|
||||||
|
workerCount = MaxWorkers
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Starting %d workers for V4 key search", workerCount)
|
||||||
|
|
||||||
|
// Start consumer goroutines
|
||||||
|
var workerWaitGroup sync.WaitGroup
|
||||||
|
workerWaitGroup.Add(workerCount)
|
||||||
|
for index := 0; index < workerCount; index++ {
|
||||||
|
go func() {
|
||||||
|
defer workerWaitGroup.Done()
|
||||||
|
e.worker(searchCtx, handle, memoryChannel, resultChannel)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Start producer goroutine
|
||||||
|
var producerWaitGroup sync.WaitGroup
|
||||||
|
producerWaitGroup.Add(1)
|
||||||
|
go func() {
|
||||||
|
defer producerWaitGroup.Done()
|
||||||
|
defer close(memoryChannel) // Close channel when producer is done
|
||||||
|
err := e.findMemory(searchCtx, handle, memoryChannel)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("Failed to find memory regions")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for producer and consumers to complete
|
||||||
|
go func() {
|
||||||
|
producerWaitGroup.Wait()
|
||||||
|
workerWaitGroup.Wait()
|
||||||
|
close(resultChannel)
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Wait for result
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return "", ctx.Err()
|
||||||
|
case result, ok := <-resultChannel:
|
||||||
|
if ok && result != "" {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "", errors.ErrNoValidKey
|
||||||
|
}
|
||||||
|
|
||||||
|
// findMemoryV4 searches for writable memory regions for V4 version
|
||||||
|
func (e *V4Extractor) findMemory(ctx context.Context, handle windows.Handle, memoryChannel chan<- []byte) error {
|
||||||
|
// Define search range
|
||||||
|
minAddr := uintptr(0x10000) // Process space usually starts from 0x10000
|
||||||
|
maxAddr := uintptr(0x7FFFFFFF) // 32-bit process space limit
|
||||||
|
|
||||||
|
if runtime.GOARCH == "amd64" {
|
||||||
|
maxAddr = uintptr(0x7FFFFFFFFFFF) // 64-bit process space limit
|
||||||
|
}
|
||||||
|
log.Debug().Msgf("Scanning memory regions from 0x%X to 0x%X", minAddr, maxAddr)
|
||||||
|
|
||||||
|
currentAddr := minAddr
|
||||||
|
|
||||||
|
for currentAddr < maxAddr {
|
||||||
|
var memInfo windows.MemoryBasicInformation
|
||||||
|
err := windows.VirtualQueryEx(handle, currentAddr, &memInfo, unsafe.Sizeof(memInfo))
|
||||||
|
if err != nil {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Skip small memory regions
|
||||||
|
if memInfo.RegionSize < 1024*1024 {
|
||||||
|
currentAddr += uintptr(memInfo.RegionSize)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if memory region is readable and private
|
||||||
|
if memInfo.State == windows.MEM_COMMIT && (memInfo.Protect&windows.PAGE_READWRITE) != 0 && memInfo.Type == MEM_PRIVATE {
|
||||||
|
// Calculate region size, ensure it doesn't exceed limit
|
||||||
|
regionSize := uintptr(memInfo.RegionSize)
|
||||||
|
if currentAddr+regionSize > maxAddr {
|
||||||
|
regionSize = maxAddr - currentAddr
|
||||||
|
}
|
||||||
|
|
||||||
|
// Read memory region
|
||||||
|
memory := make([]byte, regionSize)
|
||||||
|
if err = windows.ReadProcessMemory(handle, currentAddr, &memory[0], regionSize, nil); err == nil {
|
||||||
|
select {
|
||||||
|
case memoryChannel <- memory:
|
||||||
|
log.Debug().Msgf("Memory region for analysis: 0x%X - 0x%X, size: %d bytes", currentAddr, currentAddr+regionSize, regionSize)
|
||||||
|
case <-ctx.Done():
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Move to next memory region
|
||||||
|
currentAddr = uintptr(memInfo.BaseAddress) + uintptr(memInfo.RegionSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// workerV4 processes memory regions to find V4 version key
|
||||||
|
func (e *V4Extractor) worker(ctx context.Context, handle windows.Handle, memoryChannel <-chan []byte, resultChannel chan<- string) {
|
||||||
|
// Define search pattern for V4
|
||||||
|
keyPattern := []byte{
|
||||||
|
0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x20, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
0x2F, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
|
||||||
|
}
|
||||||
|
ptrSize := 8
|
||||||
|
littleEndianFunc := binary.LittleEndian.Uint64
|
||||||
|
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case memory, ok := <-memoryChannel:
|
||||||
|
if !ok {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
index := len(memory)
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return // Exit if context cancelled
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find pattern from end to beginning
|
||||||
|
index = bytes.LastIndex(memory[:index], keyPattern)
|
||||||
|
if index == -1 || index-ptrSize < 0 {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract and validate pointer value
|
||||||
|
ptrValue := littleEndianFunc(memory[index-ptrSize : index])
|
||||||
|
if ptrValue > 0x10000 && ptrValue < 0x7FFFFFFFFFFF {
|
||||||
|
if key := e.validateKey(handle, ptrValue); key != "" {
|
||||||
|
select {
|
||||||
|
case resultChannel <- key:
|
||||||
|
log.Debug().Msg("Valid key found: " + key)
|
||||||
|
return
|
||||||
|
default:
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
index -= 1 // Continue searching from previous position
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// validateKey validates a single key candidate
|
||||||
|
func (e *V4Extractor) validateKey(handle windows.Handle, addr uint64) string {
|
||||||
|
keyData := make([]byte, 0x20) // 32-byte key
|
||||||
|
if err := windows.ReadProcessMemory(handle, uintptr(addr), &keyData[0], uintptr(len(keyData)), nil); err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate key against database header
|
||||||
|
if e.validator.Validate(keyData) {
|
||||||
|
return hex.EncodeToString(keyData)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ""
|
||||||
|
}
|
||||||
@@ -1,57 +1,110 @@
|
|||||||
package wechat
|
package wechat
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"strings"
|
"context"
|
||||||
|
"runtime"
|
||||||
|
|
||||||
"github.com/shirou/gopsutil/v4/process"
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
log "github.com/sirupsen/logrus"
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/process"
|
||||||
)
|
)
|
||||||
|
|
||||||
const (
|
var DefaultManager *Manager
|
||||||
V3ProcessName = "WeChat"
|
|
||||||
V4ProcessName = "Weixin"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
func init() {
|
||||||
Items []*Info
|
DefaultManager = NewManager()
|
||||||
ItemMap map[string]*Info
|
DefaultManager.Load()
|
||||||
)
|
}
|
||||||
|
|
||||||
func Load() {
|
func Load() error {
|
||||||
Items = make([]*Info, 0, 2)
|
return DefaultManager.Load()
|
||||||
ItemMap = make(map[string]*Info)
|
}
|
||||||
|
|
||||||
processes, err := process.Processes()
|
func GetAccount(name string) (*Account, error) {
|
||||||
if err != nil {
|
return DefaultManager.GetAccount(name)
|
||||||
log.Println("获取进程列表失败:", err)
|
}
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for _, p := range processes {
|
func GetProcess(name string) (*model.Process, error) {
|
||||||
name, err := p.Name()
|
return DefaultManager.GetProcess(name)
|
||||||
name = strings.TrimSuffix(name, ".exe")
|
}
|
||||||
if err != nil || name != V3ProcessName && name != V4ProcessName {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// v4 存在同名进程,需要继续判断 cmdline
|
func GetAccounts() []*Account {
|
||||||
if name == V4ProcessName {
|
return DefaultManager.GetAccounts()
|
||||||
cmdline, err := p.Cmdline()
|
}
|
||||||
if err != nil {
|
|
||||||
log.Error(err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if strings.Contains(cmdline, "--") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
info, err := NewInfo(p)
|
// Manager 微信管理器
|
||||||
if err != nil {
|
type Manager struct {
|
||||||
continue
|
detector process.Detector
|
||||||
}
|
accounts []*Account
|
||||||
|
processMap map[string]*model.Process
|
||||||
|
}
|
||||||
|
|
||||||
Items = append(Items, info)
|
// NewManager 创建新的微信管理器
|
||||||
ItemMap[info.AccountName] = info
|
func NewManager() *Manager {
|
||||||
|
return &Manager{
|
||||||
|
detector: process.NewDetector(runtime.GOOS),
|
||||||
|
accounts: make([]*Account, 0),
|
||||||
|
processMap: make(map[string]*model.Process),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load 加载微信进程信息
|
||||||
|
func (m *Manager) Load() error {
|
||||||
|
// 查找微信进程
|
||||||
|
processes, err := m.detector.FindProcesses()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 转换为账号信息
|
||||||
|
accounts := make([]*Account, 0, len(processes))
|
||||||
|
processMap := make(map[string]*model.Process, len(processes))
|
||||||
|
|
||||||
|
for _, p := range processes {
|
||||||
|
account := NewAccount(p)
|
||||||
|
|
||||||
|
accounts = append(accounts, account)
|
||||||
|
if account.Name != "" {
|
||||||
|
processMap[account.Name] = p
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
m.accounts = accounts
|
||||||
|
m.processMap = processMap
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccount 获取指定名称的账号
|
||||||
|
func (m *Manager) GetAccount(name string) (*Account, error) {
|
||||||
|
p, err := m.GetProcess(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return NewAccount(p), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Manager) GetProcess(name string) (*model.Process, error) {
|
||||||
|
p, ok := m.processMap[name]
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.WeChatAccountNotFound(name)
|
||||||
|
}
|
||||||
|
return p, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetAccounts 获取所有账号
|
||||||
|
func (m *Manager) GetAccounts() []*Account {
|
||||||
|
return m.accounts
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptDatabase 便捷方法:通过账号名解密数据库
|
||||||
|
func (m *Manager) DecryptDatabase(ctx context.Context, accountName, dbPath, outputPath string) error {
|
||||||
|
// 获取账号
|
||||||
|
account, err := m.GetAccount(accountName)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 使用账号解密数据库
|
||||||
|
return account.DecryptDatabase(ctx, dbPath, outputPath)
|
||||||
|
}
|
||||||
|
|||||||
24
internal/wechat/model/process.go
Normal file
24
internal/wechat/model/process.go
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
package model
|
||||||
|
|
||||||
|
type Process struct {
|
||||||
|
PID uint32
|
||||||
|
ExePath string
|
||||||
|
Platform string
|
||||||
|
Version int
|
||||||
|
FullVersion string
|
||||||
|
Status string
|
||||||
|
DataDir string
|
||||||
|
AccountName string
|
||||||
|
}
|
||||||
|
|
||||||
|
// 平台常量定义
|
||||||
|
const (
|
||||||
|
PlatformWindows = "windows"
|
||||||
|
PlatformMacOS = "darwin"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
StatusInit = ""
|
||||||
|
StatusOffline = "offline"
|
||||||
|
StatusOnline = "online"
|
||||||
|
)
|
||||||
164
internal/wechat/process/darwin/detector.go
Normal file
164
internal/wechat/process/darwin/detector.go
Normal file
@@ -0,0 +1,164 @@
|
|||||||
|
package darwin
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/shirou/gopsutil/v4/process"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
"github.com/sjzar/chatlog/pkg/appver"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
ProcessNameOfficial = "WeChat"
|
||||||
|
ProcessNameBeta = "Weixin"
|
||||||
|
V3DBFile = "Message/msg_0.db"
|
||||||
|
V4DBFile = "db_storage/session/session.db"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Detector 实现 macOS 平台的进程检测器
|
||||||
|
type Detector struct{}
|
||||||
|
|
||||||
|
// NewDetector 创建一个新的 macOS 检测器
|
||||||
|
func NewDetector() *Detector {
|
||||||
|
return &Detector{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindProcesses 查找所有微信进程并返回它们的信息
|
||||||
|
func (d *Detector) FindProcesses() ([]*model.Process, error) {
|
||||||
|
processes, err := process.Processes()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("获取进程列表失败")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*model.Process
|
||||||
|
for _, p := range processes {
|
||||||
|
name, err := p.Name()
|
||||||
|
if err != nil || (name != ProcessNameOfficial && name != ProcessNameBeta) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取进程信息
|
||||||
|
procInfo, err := d.getProcessInfo(p)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msgf("获取进程 %d 的信息失败", p.Pid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, procInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getProcessInfo 获取微信进程的详细信息
|
||||||
|
func (d *Detector) getProcessInfo(p *process.Process) (*model.Process, error) {
|
||||||
|
procInfo := &model.Process{
|
||||||
|
PID: uint32(p.Pid),
|
||||||
|
Status: model.StatusOffline,
|
||||||
|
Platform: model.PlatformMacOS,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可执行文件路径
|
||||||
|
exePath, err := p.Exe()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("获取可执行文件路径失败")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
procInfo.ExePath = exePath
|
||||||
|
|
||||||
|
// 获取版本信息
|
||||||
|
// 注意:macOS 的版本获取方式可能与 Windows 不同
|
||||||
|
versionInfo, err := appver.New(exePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("获取版本信息失败")
|
||||||
|
procInfo.Version = 3
|
||||||
|
procInfo.FullVersion = "3.0.0"
|
||||||
|
} else {
|
||||||
|
procInfo.Version = versionInfo.Version
|
||||||
|
procInfo.FullVersion = versionInfo.FullVersion
|
||||||
|
}
|
||||||
|
|
||||||
|
// 初始化附加信息(数据目录、账户名)
|
||||||
|
if err := d.initializeProcessInfo(p, procInfo); err != nil {
|
||||||
|
log.Err(err).Msg("初始化进程信息失败")
|
||||||
|
// 即使初始化失败也返回部分信息
|
||||||
|
}
|
||||||
|
|
||||||
|
return procInfo, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// initializeProcessInfo 获取进程的数据目录和账户名
|
||||||
|
func (d *Detector) initializeProcessInfo(p *process.Process, info *model.Process) error {
|
||||||
|
// 使用 lsof 命令获取进程打开的文件
|
||||||
|
files, err := d.getOpenFiles(int(p.Pid))
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("获取打开的文件失败")
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := V3DBFile
|
||||||
|
if info.Version == 4 {
|
||||||
|
dbPath = V4DBFile
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, filePath := range files {
|
||||||
|
if strings.Contains(filePath, dbPath) {
|
||||||
|
parts := strings.Split(filePath, string(filepath.Separator))
|
||||||
|
if len(parts) < 4 {
|
||||||
|
log.Debug().Msg("无效的文件路径格式: " + filePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// v3:
|
||||||
|
// /Users/sarv/Library/Containers/com.tencent.xinWeChat/Data/Library/Application Support/com.tencent.xinWeChat/2.0b4.0.9/<id>/Message/msg_0.db
|
||||||
|
// v4:
|
||||||
|
// /Users/sarv/Library/Containers/com.tencent.xWeChat/Data/Documents/xwechat_files/<id>/db_storage/message/message_0.db
|
||||||
|
|
||||||
|
info.Status = model.StatusOnline
|
||||||
|
if info.Version == 4 {
|
||||||
|
info.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator))
|
||||||
|
info.AccountName = parts[len(parts)-4]
|
||||||
|
} else {
|
||||||
|
info.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator))
|
||||||
|
info.AccountName = parts[len(parts)-3]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getOpenFiles 使用 lsof 命令获取进程打开的文件列表
|
||||||
|
func (d *Detector) getOpenFiles(pid int) ([]string, error) {
|
||||||
|
// 执行 lsof -p <pid> 命令,使用 -F n 选项只输出文件名
|
||||||
|
cmd := exec.Command("lsof", "-p", strconv.Itoa(pid), "-F", "n")
|
||||||
|
output, err := cmd.Output()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.RunCmdFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析 lsof -F n 输出
|
||||||
|
// 格式为: n/path/to/file
|
||||||
|
lines := strings.Split(string(output), "\n")
|
||||||
|
var files []string
|
||||||
|
|
||||||
|
for _, line := range lines {
|
||||||
|
if strings.HasPrefix(line, "n") {
|
||||||
|
// 移除前缀 'n' 获取文件路径
|
||||||
|
filePath := line[1:]
|
||||||
|
if filePath != "" {
|
||||||
|
files = append(files, filePath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return files, nil
|
||||||
|
}
|
||||||
36
internal/wechat/process/detector.go
Normal file
36
internal/wechat/process/detector.go
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
package process
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/process/darwin"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/process/windows"
|
||||||
|
)
|
||||||
|
|
||||||
|
type Detector interface {
|
||||||
|
FindProcesses() ([]*model.Process, error)
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewDetector 创建适合当前平台的检测器
|
||||||
|
func NewDetector(platform string) Detector {
|
||||||
|
// 根据平台返回对应的实现
|
||||||
|
switch platform {
|
||||||
|
case "windows":
|
||||||
|
return windows.NewDetector()
|
||||||
|
case "darwin":
|
||||||
|
return darwin.NewDetector()
|
||||||
|
default:
|
||||||
|
// 默认返回一个空实现
|
||||||
|
return &nullDetector{}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// nullDetector 空实现
|
||||||
|
type nullDetector struct{}
|
||||||
|
|
||||||
|
func (d *nullDetector) FindProcesses() ([]*model.Process, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *nullDetector) GetProcessInfo(pid uint32) (*model.Process, error) {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
101
internal/wechat/process/windows/detector.go
Normal file
101
internal/wechat/process/windows/detector.go
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/shirou/gopsutil/v4/process"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
"github.com/sjzar/chatlog/pkg/appver"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
V3ProcessName = "WeChat"
|
||||||
|
V4ProcessName = "Weixin"
|
||||||
|
V3DBFile = `Msg\Misc.db`
|
||||||
|
V4DBFile = `db_storage\session\session.db`
|
||||||
|
)
|
||||||
|
|
||||||
|
// Detector 实现 Windows 平台的进程检测器
|
||||||
|
type Detector struct{}
|
||||||
|
|
||||||
|
// NewDetector 创建一个新的 Windows 检测器
|
||||||
|
func NewDetector() *Detector {
|
||||||
|
return &Detector{}
|
||||||
|
}
|
||||||
|
|
||||||
|
// FindProcesses 查找所有微信进程并返回它们的信息
|
||||||
|
func (d *Detector) FindProcesses() ([]*model.Process, error) {
|
||||||
|
processes, err := process.Processes()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("获取进程列表失败")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var result []*model.Process
|
||||||
|
for _, p := range processes {
|
||||||
|
name, err := p.Name()
|
||||||
|
name = strings.TrimSuffix(name, ".exe")
|
||||||
|
if err != nil || (name != V3ProcessName && name != V4ProcessName) {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// v4 存在同名进程,需要继续判断 cmdline
|
||||||
|
if name == V4ProcessName {
|
||||||
|
cmdline, err := p.Cmdline()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("获取进程命令行失败")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if strings.Contains(cmdline, "--") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取进程信息
|
||||||
|
procInfo, err := d.getProcessInfo(p)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msgf("获取进程 %d 的信息失败", p.Pid)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
result = append(result, procInfo)
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getProcessInfo 获取微信进程的详细信息
|
||||||
|
func (d *Detector) getProcessInfo(p *process.Process) (*model.Process, error) {
|
||||||
|
procInfo := &model.Process{
|
||||||
|
PID: uint32(p.Pid),
|
||||||
|
Status: model.StatusOffline,
|
||||||
|
Platform: model.PlatformWindows,
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取可执行文件路径
|
||||||
|
exePath, err := p.Exe()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("获取可执行文件路径失败")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
procInfo.ExePath = exePath
|
||||||
|
|
||||||
|
// 获取版本信息
|
||||||
|
versionInfo, err := appver.New(exePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("获取版本信息失败")
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
procInfo.Version = versionInfo.Version
|
||||||
|
procInfo.FullVersion = versionInfo.FullVersion
|
||||||
|
|
||||||
|
// 初始化附加信息(数据目录、账户名)
|
||||||
|
if err := initializeProcessInfo(p, procInfo); err != nil {
|
||||||
|
log.Err(err).Msg("初始化进程信息失败")
|
||||||
|
// 即使初始化失败也返回部分信息
|
||||||
|
}
|
||||||
|
|
||||||
|
return procInfo, nil
|
||||||
|
}
|
||||||
12
internal/wechat/process/windows/detector_others.go
Normal file
12
internal/wechat/process/windows/detector_others.go
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
//go:build !windows
|
||||||
|
|
||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"github.com/shirou/gopsutil/v4/process"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
func initializeProcessInfo(p *process.Process, info *model.Process) error {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
48
internal/wechat/process/windows/detector_windows.go
Normal file
48
internal/wechat/process/windows/detector_windows.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package windows
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
"github.com/shirou/gopsutil/v4/process"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// initializeProcessInfo 获取进程的数据目录和账户名
|
||||||
|
func initializeProcessInfo(p *process.Process, info *model.Process) error {
|
||||||
|
files, err := p.OpenFiles()
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msgf("获取进程 %d 的打开文件失败", p.Pid)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
dbPath := V3DBFile
|
||||||
|
if info.Version == 4 {
|
||||||
|
dbPath = V4DBFile
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, f := range files {
|
||||||
|
if strings.HasSuffix(f.Path, dbPath) {
|
||||||
|
filePath := f.Path[4:] // 移除 "\\?\" 前缀
|
||||||
|
parts := strings.Split(filePath, string(filepath.Separator))
|
||||||
|
if len(parts) < 4 {
|
||||||
|
log.Debug().Msg("无效的文件路径: " + filePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
info.Status = model.StatusOnline
|
||||||
|
if info.Version == 4 {
|
||||||
|
info.DataDir = strings.Join(parts[:len(parts)-3], string(filepath.Separator))
|
||||||
|
info.AccountName = parts[len(parts)-4]
|
||||||
|
} else {
|
||||||
|
info.DataDir = strings.Join(parts[:len(parts)-2], string(filepath.Separator))
|
||||||
|
info.AccountName = parts[len(parts)-3]
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
134
internal/wechat/wechat.go
Normal file
134
internal/wechat/wechat.go
Normal file
@@ -0,0 +1,134 @@
|
|||||||
|
package wechat
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/decrypt"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/key"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechat/model"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Account 表示一个微信账号
|
||||||
|
type Account struct {
|
||||||
|
Name string
|
||||||
|
Platform string
|
||||||
|
Version int
|
||||||
|
FullVersion string
|
||||||
|
DataDir string
|
||||||
|
Key string
|
||||||
|
PID uint32
|
||||||
|
ExePath string
|
||||||
|
Status string
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewAccount 创建新的账号对象
|
||||||
|
func NewAccount(proc *model.Process) *Account {
|
||||||
|
return &Account{
|
||||||
|
Name: proc.AccountName,
|
||||||
|
Platform: proc.Platform,
|
||||||
|
Version: proc.Version,
|
||||||
|
FullVersion: proc.FullVersion,
|
||||||
|
DataDir: proc.DataDir,
|
||||||
|
PID: proc.PID,
|
||||||
|
ExePath: proc.ExePath,
|
||||||
|
Status: proc.Status,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// RefreshStatus 刷新账号的进程状态
|
||||||
|
func (a *Account) RefreshStatus() error {
|
||||||
|
// 查找所有微信进程
|
||||||
|
Load()
|
||||||
|
|
||||||
|
process, err := GetProcess(a.Name)
|
||||||
|
if err != nil {
|
||||||
|
a.Status = model.StatusOffline
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if process.AccountName == a.Name {
|
||||||
|
// 更新进程信息
|
||||||
|
a.PID = process.PID
|
||||||
|
a.ExePath = process.ExePath
|
||||||
|
a.Platform = process.Platform
|
||||||
|
a.Version = process.Version
|
||||||
|
a.FullVersion = process.FullVersion
|
||||||
|
a.Status = process.Status
|
||||||
|
a.DataDir = process.DataDir
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetKey 获取账号的密钥
|
||||||
|
func (a *Account) GetKey(ctx context.Context) (string, error) {
|
||||||
|
// 如果已经有密钥,直接返回
|
||||||
|
if a.Key != "" {
|
||||||
|
return a.Key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 刷新进程状态
|
||||||
|
if err := a.RefreshStatus(); err != nil {
|
||||||
|
return "", errors.RefreshProcessStatusFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查账号状态
|
||||||
|
if a.Status != model.StatusOnline {
|
||||||
|
return "", errors.WeChatAccountNotOnline(a.Name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建密钥提取器 - 使用新的接口,传入平台和版本信息
|
||||||
|
extractor, err := key.NewExtractor(a.Platform, a.Version)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
process, err := GetProcess(a.Name)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
validator, err := decrypt.NewValidator(process.Platform, process.Version, process.DataDir)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
extractor.SetValidate(validator)
|
||||||
|
|
||||||
|
// 提取密钥
|
||||||
|
key, err := extractor.Extract(ctx, process)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存密钥
|
||||||
|
a.Key = key
|
||||||
|
return key, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DecryptDatabase 解密数据库
|
||||||
|
func (a *Account) DecryptDatabase(ctx context.Context, dbPath, outputPath string) error {
|
||||||
|
// 获取密钥
|
||||||
|
hexKey, err := a.GetKey(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建解密器 - 传入平台信息和版本
|
||||||
|
decryptor, err := decrypt.NewDecryptor(a.Platform, a.Version)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建输出文件
|
||||||
|
output, err := os.Create(outputPath)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer output.Close()
|
||||||
|
|
||||||
|
// 解密数据库
|
||||||
|
return decryptor.Decrypt(ctx, dbPath, hexKey, output)
|
||||||
|
}
|
||||||
@@ -1,269 +0,0 @@
|
|||||||
package wechatdb
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql"
|
|
||||||
"fmt"
|
|
||||||
|
|
||||||
"github.com/sjzar/chatlog/pkg/model"
|
|
||||||
"github.com/sjzar/chatlog/pkg/util"
|
|
||||||
|
|
||||||
log "github.com/sirupsen/logrus"
|
|
||||||
)
|
|
||||||
|
|
||||||
var (
|
|
||||||
ContactFileV3 = "^MicroMsg.db$"
|
|
||||||
ContactFileV4 = "contact.db$"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Contact struct {
|
|
||||||
version int
|
|
||||||
dbFile string
|
|
||||||
db *sql.DB
|
|
||||||
|
|
||||||
Contact map[string]*model.Contact // 好友和群聊信息,Key UserName
|
|
||||||
ChatRoom map[string]*model.ChatRoom // 群聊信息,Key UserName
|
|
||||||
Sessions []*model.Session // 历史会话,按时间倒序
|
|
||||||
|
|
||||||
// Quick Search
|
|
||||||
ChatRoomUsers map[string]*model.Contact // 群聊成员信息,Key UserName
|
|
||||||
Alias2Contack map[string]*model.Contact // 别名到联系人的映射
|
|
||||||
Remark2Contack map[string]*model.Contact // 备注名到联系人的映射
|
|
||||||
NickName2Contack map[string]*model.Contact // 昵称到联系人的映射
|
|
||||||
}
|
|
||||||
|
|
||||||
func NewContact(path string, version int) (*Contact, error) {
|
|
||||||
c := &Contact{
|
|
||||||
version: version,
|
|
||||||
}
|
|
||||||
|
|
||||||
files, err := util.FindFilesWithPatterns(path, ContactFileV3, true)
|
|
||||||
if err != nil {
|
|
||||||
return nil, fmt.Errorf("查找数据库文件失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(files) == 0 {
|
|
||||||
return nil, fmt.Errorf("未找到任何数据库文件: %s", path)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.dbFile = files[0]
|
|
||||||
|
|
||||||
c.db, err = sql.Open("sqlite3", c.dbFile)
|
|
||||||
if err != nil {
|
|
||||||
log.Printf("警告: 连接数据库 %s 失败: %v", c.dbFile, err)
|
|
||||||
return nil, fmt.Errorf("连接数据库失败: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
c.loadContact()
|
|
||||||
c.loadChatRoom()
|
|
||||||
c.loadSession()
|
|
||||||
c.fillChatRoomInfo()
|
|
||||||
|
|
||||||
return c, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) loadContact() {
|
|
||||||
contactMap := make(map[string]*model.Contact)
|
|
||||||
chatRoomUserMap := make(map[string]*model.Contact)
|
|
||||||
aliasMap := make(map[string]*model.Contact)
|
|
||||||
remarkMap := make(map[string]*model.Contact)
|
|
||||||
nickNameMap := make(map[string]*model.Contact)
|
|
||||||
rows, err := c.db.Query("SELECT UserName, Alias, Remark, NickName, Reserved1 FROM Contact")
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("查询联系人失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
for rows.Next() {
|
|
||||||
var contactv3 model.ContactV3
|
|
||||||
|
|
||||||
if err := rows.Scan(
|
|
||||||
&contactv3.UserName,
|
|
||||||
&contactv3.Alias,
|
|
||||||
&contactv3.Remark,
|
|
||||||
&contactv3.NickName,
|
|
||||||
&contactv3.Reserved1,
|
|
||||||
); err != nil {
|
|
||||||
log.Printf("警告: 扫描联系人行失败: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
contact := contactv3.Wrap()
|
|
||||||
|
|
||||||
if contact.IsFriend {
|
|
||||||
contactMap[contact.UserName] = contact
|
|
||||||
if contact.Alias != "" {
|
|
||||||
aliasMap[contact.Alias] = contact
|
|
||||||
}
|
|
||||||
if contact.Remark != "" {
|
|
||||||
remarkMap[contact.Remark] = contact
|
|
||||||
}
|
|
||||||
if contact.NickName != "" {
|
|
||||||
nickNameMap[contact.NickName] = contact
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
chatRoomUserMap[contact.UserName] = contact
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
|
|
||||||
c.Contact = contactMap
|
|
||||||
c.ChatRoomUsers = chatRoomUserMap
|
|
||||||
c.Alias2Contack = aliasMap
|
|
||||||
c.Remark2Contack = remarkMap
|
|
||||||
c.NickName2Contack = nickNameMap
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) loadChatRoom() {
|
|
||||||
|
|
||||||
chatRoomMap := make(map[string]*model.ChatRoom)
|
|
||||||
rows, err := c.db.Query("SELECT ChatRoomName, Reserved2, RoomData FROM ChatRoom")
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("查询群聊失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var chatRoom model.ChatRoomV3
|
|
||||||
if err := rows.Scan(
|
|
||||||
&chatRoom.ChatRoomName,
|
|
||||||
&chatRoom.Reserved2,
|
|
||||||
&chatRoom.RoomData,
|
|
||||||
); err != nil {
|
|
||||||
log.Printf("警告: 扫描群聊行失败: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
chatRoomMap[chatRoom.ChatRoomName] = chatRoom.Wrap()
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
c.ChatRoom = chatRoomMap
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) loadSession() {
|
|
||||||
|
|
||||||
sessions := make([]*model.Session, 0)
|
|
||||||
rows, err := c.db.Query("SELECT strUsrName, nOrder, strNickName, strContent, nTime FROM Session ORDER BY nOrder DESC")
|
|
||||||
if err != nil {
|
|
||||||
log.Errorf("查询群聊失败: %v", err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
for rows.Next() {
|
|
||||||
var sessionV3 model.SessionV3
|
|
||||||
if err := rows.Scan(
|
|
||||||
&sessionV3.StrUsrName,
|
|
||||||
&sessionV3.NOrder,
|
|
||||||
&sessionV3.StrNickName,
|
|
||||||
&sessionV3.StrContent,
|
|
||||||
&sessionV3.NTime,
|
|
||||||
); err != nil {
|
|
||||||
log.Printf("警告: 扫描历史会话失败: %v", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
session := sessionV3.Wrap()
|
|
||||||
sessions = append(sessions, session)
|
|
||||||
|
|
||||||
}
|
|
||||||
rows.Close()
|
|
||||||
c.Sessions = sessions
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) ListContact() ([]*model.Contact, error) {
|
|
||||||
contacts := make([]*model.Contact, 0, len(c.Contact))
|
|
||||||
for _, contact := range c.Contact {
|
|
||||||
contacts = append(contacts, contact)
|
|
||||||
}
|
|
||||||
return contacts, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) ListChatRoom() ([]*model.ChatRoom, error) {
|
|
||||||
chatRooms := make([]*model.ChatRoom, 0, len(c.ChatRoom))
|
|
||||||
for _, chatRoom := range c.ChatRoom {
|
|
||||||
chatRooms = append(chatRooms, chatRoom)
|
|
||||||
}
|
|
||||||
return chatRooms, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) GetContact(key string) *model.Contact {
|
|
||||||
if contact, ok := c.Contact[key]; ok {
|
|
||||||
return contact
|
|
||||||
}
|
|
||||||
if contact, ok := c.Alias2Contack[key]; ok {
|
|
||||||
return contact
|
|
||||||
}
|
|
||||||
if contact, ok := c.Remark2Contack[key]; ok {
|
|
||||||
return contact
|
|
||||||
}
|
|
||||||
if contact, ok := c.NickName2Contack[key]; ok {
|
|
||||||
return contact
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) GetChatRoom(name string) *model.ChatRoom {
|
|
||||||
if chatRoom, ok := c.ChatRoom[name]; ok {
|
|
||||||
return chatRoom
|
|
||||||
}
|
|
||||||
|
|
||||||
if contact := c.GetContact(name); contact != nil {
|
|
||||||
if chatRoom, ok := c.ChatRoom[contact.UserName]; ok {
|
|
||||||
return chatRoom
|
|
||||||
} else {
|
|
||||||
// 被删除的群聊,在 ChatRoom 记录中没有了,但是能找到 Contact,做下 Mock
|
|
||||||
return &model.ChatRoom{
|
|
||||||
Name: contact.UserName,
|
|
||||||
Remark: contact.Remark,
|
|
||||||
NickName: contact.NickName,
|
|
||||||
Users: make([]model.ChatRoomUser, 0),
|
|
||||||
User2DisplayName: make(map[string]string),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) GetSession(limit int) []*model.Session {
|
|
||||||
if limit <= 0 {
|
|
||||||
limit = len(c.Sessions)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(c.Sessions) < limit {
|
|
||||||
limit = len(c.Sessions)
|
|
||||||
}
|
|
||||||
return c.Sessions[:limit]
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) getFullContact(userName string) *model.Contact {
|
|
||||||
if contact := c.GetContact(userName); contact != nil {
|
|
||||||
return contact
|
|
||||||
}
|
|
||||||
if contact, ok := c.ChatRoomUsers[userName]; ok {
|
|
||||||
return contact
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) fillChatRoomInfo() {
|
|
||||||
for i := range c.ChatRoom {
|
|
||||||
if contact := c.GetContact(c.ChatRoom[i].Name); contact != nil {
|
|
||||||
c.ChatRoom[i].Remark = contact.Remark
|
|
||||||
c.ChatRoom[i].NickName = contact.NickName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (c *Contact) MessageFillInfo(msg *model.Message) {
|
|
||||||
talker := msg.Talker
|
|
||||||
if msg.IsChatRoom {
|
|
||||||
talker = msg.ChatRoomSender
|
|
||||||
if chatRoom := c.GetChatRoom(msg.Talker); chatRoom != nil {
|
|
||||||
msg.CharRoomName = chatRoom.DisplayName()
|
|
||||||
if displayName, ok := chatRoom.User2DisplayName[talker]; ok {
|
|
||||||
msg.DisplayName = displayName
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if msg.DisplayName == "" && msg.IsSender != 1 {
|
|
||||||
if contact := c.getFullContact(talker); contact != nil {
|
|
||||||
msg.DisplayName = contact.DisplayName()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
654
internal/wechatdb/datasource/darwinv3/datasource.go
Normal file
654
internal/wechatdb/datasource/darwinv3/datasource.go
Normal file
@@ -0,0 +1,654 @@
|
|||||||
|
package darwinv3
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/model"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
|
||||||
|
"github.com/sjzar/chatlog/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Message = "message"
|
||||||
|
Contact = "contact"
|
||||||
|
ChatRoom = "chatroom"
|
||||||
|
Session = "session"
|
||||||
|
Media = "media"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Groups = []*dbm.Group{
|
||||||
|
{
|
||||||
|
Name: Message,
|
||||||
|
Pattern: `^msg_([0-9]?[0-9])?\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: Contact,
|
||||||
|
Pattern: `^wccontact_new2\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: ChatRoom,
|
||||||
|
Pattern: `group_new\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: Session,
|
||||||
|
Pattern: `^session_new\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: Media,
|
||||||
|
Pattern: `^hldata\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataSource struct {
|
||||||
|
path string
|
||||||
|
dbm *dbm.DBManager
|
||||||
|
|
||||||
|
talkerDBMap map[string]string
|
||||||
|
user2DisplayName map[string]string
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string) (*DataSource, error) {
|
||||||
|
ds := &DataSource{
|
||||||
|
path: path,
|
||||||
|
dbm: dbm.NewDBManager(path),
|
||||||
|
talkerDBMap: make(map[string]string),
|
||||||
|
user2DisplayName: make(map[string]string),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range Groups {
|
||||||
|
ds.dbm.AddGroup(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ds.dbm.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ds.initMessageDbs(); err != nil {
|
||||||
|
return nil, errors.DBInitFailed(err)
|
||||||
|
}
|
||||||
|
if err := ds.initChatRoomDb(); err != nil {
|
||||||
|
return nil, errors.DBInitFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ds.dbm.AddCallback(Message, func(event fsnotify.Event) error {
|
||||||
|
if !event.Op.Has(fsnotify.Create) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := ds.initMessageDbs(); err != nil {
|
||||||
|
log.Err(err).Msgf("Failed to reinitialize message DBs: %s", event.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
ds.dbm.AddCallback(ChatRoom, func(event fsnotify.Event) error {
|
||||||
|
if !event.Op.Has(fsnotify.Create) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := ds.initChatRoomDb(); err != nil {
|
||||||
|
log.Err(err).Msgf("Failed to reinitialize chatroom DB: %s", event.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return ds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) SetCallback(name string, callback func(event fsnotify.Event) error) error {
|
||||||
|
return ds.dbm.AddCallback(name, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) initMessageDbs() error {
|
||||||
|
|
||||||
|
dbPaths, err := ds.dbm.GetDBPath(Message)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "db file not found") {
|
||||||
|
ds.talkerDBMap = make(map[string]string)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// 处理每个数据库文件
|
||||||
|
talkerDBMap := make(map[string]string)
|
||||||
|
for _, filePath := range dbPaths {
|
||||||
|
db, err := ds.dbm.OpenDB(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msgf("获取数据库 %s 失败", filePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取所有表名
|
||||||
|
rows, err := db.Query("SELECT name FROM sqlite_master WHERE type='table' AND name LIKE 'Chat_%'")
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msgf("数据库 %s 中没有 Chat 表", filePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var tableName string
|
||||||
|
if err := rows.Scan(&tableName); err != nil {
|
||||||
|
log.Err(err).Msgf("数据库 %s 扫描表名失败", filePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从表名中提取可能的talker信息
|
||||||
|
talkerMd5 := extractTalkerFromTableName(tableName)
|
||||||
|
if talkerMd5 == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
talkerDBMap[talkerMd5] = filePath
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
ds.talkerDBMap = talkerDBMap
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) initChatRoomDb() error {
|
||||||
|
db, err := ds.dbm.GetDB(ChatRoom)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "db file not found") {
|
||||||
|
ds.user2DisplayName = make(map[string]string)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := db.Query("SELECT m_nsUsrName, IFNULL(nickname,\"\") FROM GroupMember")
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msg("获取群聊成员失败")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
user2DisplayName := make(map[string]string)
|
||||||
|
for rows.Next() {
|
||||||
|
var user string
|
||||||
|
var nickName string
|
||||||
|
if err := rows.Scan(&user, &nickName); err != nil {
|
||||||
|
log.Err(err).Msg("扫描表名失败")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
user2DisplayName[user] = nickName
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
ds.user2DisplayName = user2DisplayName
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
|
||||||
|
if talker == "" {
|
||||||
|
return nil, errors.ErrTalkerEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析talker参数,支持多个talker(以英文逗号分隔)
|
||||||
|
talkers := util.Str2List(talker, ",")
|
||||||
|
if len(talkers) == 0 {
|
||||||
|
return nil, errors.ErrTalkerEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析sender参数,支持多个发送者(以英文逗号分隔)
|
||||||
|
senders := util.Str2List(sender, ",")
|
||||||
|
|
||||||
|
// 预编译正则表达式(如果有keyword)
|
||||||
|
var regex *regexp.Regexp
|
||||||
|
if keyword != "" {
|
||||||
|
var err error
|
||||||
|
regex, err = regexp.Compile(keyword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed("invalid regex pattern", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从每个相关数据库中查询消息,并在读取时进行过滤
|
||||||
|
filteredMessages := []*model.Message{}
|
||||||
|
|
||||||
|
// 对每个talker进行查询
|
||||||
|
for _, talkerItem := range talkers {
|
||||||
|
// 检查上下文是否已取消
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 在 darwinv3 中,需要先找到对应的数据库
|
||||||
|
_talkerMd5Bytes := md5.Sum([]byte(talkerItem))
|
||||||
|
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||||
|
dbPath, ok := ds.talkerDBMap[talkerMd5]
|
||||||
|
if !ok {
|
||||||
|
// 如果找不到对应的数据库,跳过此talker
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := ds.dbm.OpenDB(dbPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Msgf("数据库 %s 未打开", dbPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
tableName := fmt.Sprintf("Chat_%s", talkerMd5)
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT msgCreateTime, msgContent, messageType, mesDes
|
||||||
|
FROM %s
|
||||||
|
WHERE msgCreateTime >= ? AND msgCreateTime <= ?
|
||||||
|
ORDER BY msgCreateTime ASC
|
||||||
|
`, tableName)
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
rows, err := db.QueryContext(ctx, query, startTime.Unix(), endTime.Unix())
|
||||||
|
if err != nil {
|
||||||
|
// 如果表不存在,跳过此talker
|
||||||
|
if strings.Contains(err.Error(), "no such table") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Err(err).Msgf("从数据库 %s 查询消息失败", dbPath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理查询结果,在读取时进行过滤
|
||||||
|
for rows.Next() {
|
||||||
|
var msg model.MessageDarwinV3
|
||||||
|
err := rows.Scan(
|
||||||
|
&msg.MsgCreateTime,
|
||||||
|
&msg.MsgContent,
|
||||||
|
&msg.MessageType,
|
||||||
|
&msg.MesDes,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
log.Err(err).Msgf("扫描消息行失败")
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将消息包装为通用模型
|
||||||
|
message := msg.Wrap(talkerItem)
|
||||||
|
|
||||||
|
// 应用sender过滤
|
||||||
|
if len(senders) > 0 {
|
||||||
|
senderMatch := false
|
||||||
|
for _, s := range senders {
|
||||||
|
if message.Sender == s {
|
||||||
|
senderMatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !senderMatch {
|
||||||
|
continue // 不匹配sender,跳过此消息
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用keyword过滤
|
||||||
|
if regex != nil {
|
||||||
|
plainText := message.PlainTextContent()
|
||||||
|
if !regex.MatchString(plainText) {
|
||||||
|
continue // 不匹配keyword,跳过此消息
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过所有过滤条件,保留此消息
|
||||||
|
filteredMessages = append(filteredMessages, message)
|
||||||
|
|
||||||
|
// 检查是否已经满足分页处理数量
|
||||||
|
if limit > 0 && len(filteredMessages) >= offset+limit {
|
||||||
|
// 已经获取了足够的消息,可以提前返回
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// 对所有消息按时间排序
|
||||||
|
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||||
|
return filteredMessages[i].Seq < filteredMessages[j].Seq
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理分页
|
||||||
|
if offset >= len(filteredMessages) {
|
||||||
|
return []*model.Message{}, nil
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(filteredMessages) {
|
||||||
|
end = len(filteredMessages)
|
||||||
|
}
|
||||||
|
return filteredMessages[offset:end], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对所有消息按时间排序
|
||||||
|
// FIXME 不同 talker 需要使用 Time 排序
|
||||||
|
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||||
|
return filteredMessages[i].Time.Before(filteredMessages[j].Time)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理分页
|
||||||
|
if limit > 0 {
|
||||||
|
if offset >= len(filteredMessages) {
|
||||||
|
return []*model.Message{}, nil
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(filteredMessages) {
|
||||||
|
end = len(filteredMessages)
|
||||||
|
}
|
||||||
|
return filteredMessages[offset:end], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredMessages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从表名中提取 talker
|
||||||
|
func extractTalkerFromTableName(tableName string) string {
|
||||||
|
|
||||||
|
if !strings.HasPrefix(tableName, "Chat_") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
if strings.HasSuffix(tableName, "_dels") {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
return strings.TrimPrefix(tableName, "Chat_")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContacts 实现获取联系人信息的方法
|
||||||
|
func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
// 按照关键字查询
|
||||||
|
query = `SELECT IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), m_uiSex, IFNULL(m_nsAliasName,"")
|
||||||
|
FROM WCContact
|
||||||
|
WHERE m_nsUsrName = ? OR nickname = ? OR m_nsRemark = ? OR m_nsAliasName = ?`
|
||||||
|
args = []interface{}{key, key, key, key}
|
||||||
|
} else {
|
||||||
|
// 查询所有联系人
|
||||||
|
query = `SELECT IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), m_uiSex, IFNULL(m_nsAliasName,"")
|
||||||
|
FROM WCContact`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加排序、分页
|
||||||
|
query += ` ORDER BY m_nsUsrName`
|
||||||
|
if limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||||
|
if offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
db, err := ds.dbm.GetDB(Contact)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
contacts := []*model.Contact{}
|
||||||
|
for rows.Next() {
|
||||||
|
var contactDarwinV3 model.ContactDarwinV3
|
||||||
|
err := rows.Scan(
|
||||||
|
&contactDarwinV3.M_nsUsrName,
|
||||||
|
&contactDarwinV3.Nickname,
|
||||||
|
&contactDarwinV3.M_nsRemark,
|
||||||
|
&contactDarwinV3.M_uiSex,
|
||||||
|
&contactDarwinV3.M_nsAliasName,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts = append(contacts, contactDarwinV3.Wrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChatRooms 实现获取群聊信息的方法
|
||||||
|
func (ds *DataSource) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
// 按照关键字查询
|
||||||
|
query = `SELECT IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
|
||||||
|
FROM GroupContact
|
||||||
|
WHERE m_nsUsrName = ? OR nickname = ? OR m_nsRemark = ?`
|
||||||
|
args = []interface{}{key, key, key}
|
||||||
|
} else {
|
||||||
|
// 查询所有群聊
|
||||||
|
query = `SELECT IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
|
||||||
|
FROM GroupContact`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加排序、分页
|
||||||
|
query += ` ORDER BY m_nsUsrName`
|
||||||
|
if limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||||
|
if offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
db, err := ds.dbm.GetDB(ChatRoom)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
chatRooms := []*model.ChatRoom{}
|
||||||
|
for rows.Next() {
|
||||||
|
var chatRoomDarwinV3 model.ChatRoomDarwinV3
|
||||||
|
err := rows.Scan(
|
||||||
|
&chatRoomDarwinV3.M_nsUsrName,
|
||||||
|
&chatRoomDarwinV3.Nickname,
|
||||||
|
&chatRoomDarwinV3.M_nsRemark,
|
||||||
|
&chatRoomDarwinV3.M_nsChatRoomMemList,
|
||||||
|
&chatRoomDarwinV3.M_nsChatRoomAdminList,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRooms = append(chatRooms, chatRoomDarwinV3.Wrap(ds.user2DisplayName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到群聊,尝试通过联系人查找
|
||||||
|
if len(chatRooms) == 0 && key != "" {
|
||||||
|
contacts, err := ds.GetContacts(ctx, key, 1, 0)
|
||||||
|
if err == nil && len(contacts) > 0 && strings.HasSuffix(contacts[0].UserName, "@chatroom") {
|
||||||
|
// 再次尝试通过用户名查找群聊
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT IFNULL(m_nsUsrName,""), IFNULL(nickname,""), IFNULL(m_nsRemark,""), IFNULL(m_nsChatRoomMemList,""), IFNULL(m_nsChatRoomAdminList,"")
|
||||||
|
FROM GroupContact
|
||||||
|
WHERE m_nsUsrName = ?`,
|
||||||
|
contacts[0].UserName)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var chatRoomDarwinV3 model.ChatRoomDarwinV3
|
||||||
|
err := rows.Scan(
|
||||||
|
&chatRoomDarwinV3.M_nsUsrName,
|
||||||
|
&chatRoomDarwinV3.Nickname,
|
||||||
|
&chatRoomDarwinV3.M_nsRemark,
|
||||||
|
&chatRoomDarwinV3.M_nsChatRoomMemList,
|
||||||
|
&chatRoomDarwinV3.M_nsChatRoomAdminList,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRooms = append(chatRooms, chatRoomDarwinV3.Wrap(ds.user2DisplayName))
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
|
||||||
|
if len(chatRooms) == 0 {
|
||||||
|
chatRooms = append(chatRooms, &model.ChatRoom{
|
||||||
|
Name: contacts[0].UserName,
|
||||||
|
Users: make([]model.ChatRoomUser, 0),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chatRooms, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSessions 实现获取会话信息的方法
|
||||||
|
func (ds *DataSource) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
// 按照关键字查询
|
||||||
|
query = `SELECT m_nsUserName, m_uLastTime
|
||||||
|
FROM SessionAbstract
|
||||||
|
WHERE m_nsUserName = ?`
|
||||||
|
args = []interface{}{key}
|
||||||
|
} else {
|
||||||
|
// 查询所有会话
|
||||||
|
query = `SELECT m_nsUserName, m_uLastTime
|
||||||
|
FROM SessionAbstract`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加排序、分页
|
||||||
|
query += ` ORDER BY m_uLastTime DESC`
|
||||||
|
if limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||||
|
if offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
db, err := ds.dbm.GetDB(Session)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
sessions := []*model.Session{}
|
||||||
|
for rows.Next() {
|
||||||
|
var sessionDarwinV3 model.SessionDarwinV3
|
||||||
|
err := rows.Scan(
|
||||||
|
&sessionDarwinV3.M_nsUserName,
|
||||||
|
&sessionDarwinV3.M_uLastTime,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 包装成通用模型
|
||||||
|
session := sessionDarwinV3.Wrap()
|
||||||
|
|
||||||
|
// 尝试获取联系人信息以补充会话信息
|
||||||
|
contacts, err := ds.GetContacts(ctx, session.UserName, 1, 0)
|
||||||
|
if err == nil && len(contacts) > 0 {
|
||||||
|
session.NickName = contacts[0].DisplayName()
|
||||||
|
} else {
|
||||||
|
// 尝试获取群聊信息
|
||||||
|
chatRooms, err := ds.GetChatRooms(ctx, session.UserName, 1, 0)
|
||||||
|
if err == nil && len(chatRooms) > 0 {
|
||||||
|
session.NickName = chatRooms[0].DisplayName()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions = append(sessions, session)
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) GetMedia(ctx context.Context, _type string, key string) (*model.Media, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, errors.ErrKeyEmpty
|
||||||
|
}
|
||||||
|
query := `SELECT
|
||||||
|
r.mediaMd5,
|
||||||
|
r.mediaSize,
|
||||||
|
r.inodeNumber,
|
||||||
|
r.modifyTime,
|
||||||
|
d.relativePath,
|
||||||
|
d.fileName
|
||||||
|
FROM
|
||||||
|
HlinkMediaRecord r
|
||||||
|
JOIN
|
||||||
|
HlinkMediaDetail d ON r.inodeNumber = d.inodeNumber
|
||||||
|
WHERE
|
||||||
|
r.mediaMd5 = ?`
|
||||||
|
args := []interface{}{key}
|
||||||
|
// 执行查询
|
||||||
|
db, err := ds.dbm.GetDB(Media)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var media *model.Media
|
||||||
|
for rows.Next() {
|
||||||
|
var mediaDarwinV3 model.MediaDarwinV3
|
||||||
|
err := rows.Scan(
|
||||||
|
&mediaDarwinV3.MediaMd5,
|
||||||
|
&mediaDarwinV3.MediaSize,
|
||||||
|
&mediaDarwinV3.InodeNumber,
|
||||||
|
&mediaDarwinV3.ModifyTime,
|
||||||
|
&mediaDarwinV3.RelativePath,
|
||||||
|
&mediaDarwinV3.FileName,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 包装成通用模型
|
||||||
|
media = mediaDarwinV3.Wrap()
|
||||||
|
}
|
||||||
|
|
||||||
|
if media == nil {
|
||||||
|
return nil, errors.ErrMediaNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return media, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close 实现关闭数据库连接的方法
|
||||||
|
func (ds *DataSource) Close() error {
|
||||||
|
return ds.dbm.Close()
|
||||||
|
}
|
||||||
52
internal/wechatdb/datasource/datasource.go
Normal file
52
internal/wechatdb/datasource/datasource.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package datasource
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/model"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechatdb/datasource/darwinv3"
|
||||||
|
v4 "github.com/sjzar/chatlog/internal/wechatdb/datasource/v4"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechatdb/datasource/windowsv3"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DataSource interface {
|
||||||
|
|
||||||
|
// 消息
|
||||||
|
GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error)
|
||||||
|
|
||||||
|
// 联系人
|
||||||
|
GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error)
|
||||||
|
|
||||||
|
// 群聊
|
||||||
|
GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error)
|
||||||
|
|
||||||
|
// 最近会话
|
||||||
|
GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error)
|
||||||
|
|
||||||
|
// 媒体
|
||||||
|
GetMedia(ctx context.Context, _type string, key string) (*model.Media, error)
|
||||||
|
|
||||||
|
// 设置回调函数
|
||||||
|
SetCallback(name string, callback func(event fsnotify.Event) error) error
|
||||||
|
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string, platform string, version int) (DataSource, error) {
|
||||||
|
switch {
|
||||||
|
case platform == "windows" && version == 3:
|
||||||
|
return windowsv3.New(path)
|
||||||
|
case platform == "windows" && version == 4:
|
||||||
|
return v4.New(path)
|
||||||
|
case platform == "darwin" && version == 3:
|
||||||
|
return darwinv3.New(path)
|
||||||
|
case platform == "darwin" && version == 4:
|
||||||
|
return v4.New(path)
|
||||||
|
default:
|
||||||
|
return nil, errors.PlatformUnsupported(platform, version)
|
||||||
|
}
|
||||||
|
}
|
||||||
170
internal/wechatdb/datasource/dbm/dbm.go
Normal file
170
internal/wechatdb/datasource/dbm/dbm.go
Normal file
@@ -0,0 +1,170 @@
|
|||||||
|
package dbm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"runtime"
|
||||||
|
"sync"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/pkg/filecopy"
|
||||||
|
"github.com/sjzar/chatlog/pkg/filemonitor"
|
||||||
|
)
|
||||||
|
|
||||||
|
type DBManager struct {
|
||||||
|
path string
|
||||||
|
fm *filemonitor.FileMonitor
|
||||||
|
fgs map[string]*filemonitor.FileGroup
|
||||||
|
dbs map[string]*sql.DB
|
||||||
|
dbPaths map[string][]string
|
||||||
|
mutex sync.RWMutex
|
||||||
|
}
|
||||||
|
|
||||||
|
func NewDBManager(path string) *DBManager {
|
||||||
|
return &DBManager{
|
||||||
|
path: path,
|
||||||
|
fm: filemonitor.NewFileMonitor(),
|
||||||
|
fgs: make(map[string]*filemonitor.FileGroup),
|
||||||
|
dbs: make(map[string]*sql.DB),
|
||||||
|
dbPaths: make(map[string][]string),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) AddGroup(g *Group) error {
|
||||||
|
fg, err := filemonitor.NewFileGroup(g.Name, d.path, g.Pattern, g.BlackList)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
fg.AddCallback(d.Callback)
|
||||||
|
d.fm.AddGroup(fg)
|
||||||
|
d.mutex.Lock()
|
||||||
|
d.fgs[g.Name] = fg
|
||||||
|
d.mutex.Unlock()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) AddCallback(name string, callback func(event fsnotify.Event) error) error {
|
||||||
|
d.mutex.RLock()
|
||||||
|
fg, ok := d.fgs[name]
|
||||||
|
d.mutex.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return errors.FileGroupNotFound(name)
|
||||||
|
}
|
||||||
|
fg.AddCallback(callback)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) GetDB(name string) (*sql.DB, error) {
|
||||||
|
dbPaths, err := d.GetDBPath(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return d.OpenDB(dbPaths[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) GetDBs(name string) ([]*sql.DB, error) {
|
||||||
|
dbPaths, err := d.GetDBPath(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dbs := make([]*sql.DB, 0)
|
||||||
|
for _, file := range dbPaths {
|
||||||
|
db, err := d.OpenDB(file)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
dbs = append(dbs, db)
|
||||||
|
}
|
||||||
|
return dbs, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) GetDBPath(name string) ([]string, error) {
|
||||||
|
d.mutex.RLock()
|
||||||
|
dbPaths, ok := d.dbPaths[name]
|
||||||
|
d.mutex.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
d.mutex.RLock()
|
||||||
|
fg, ok := d.fgs[name]
|
||||||
|
d.mutex.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return nil, errors.FileGroupNotFound(name)
|
||||||
|
}
|
||||||
|
list, err := fg.List()
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.DBFileNotFound(d.path, fg.PatternStr, err)
|
||||||
|
}
|
||||||
|
if len(list) == 0 {
|
||||||
|
return nil, errors.DBFileNotFound(d.path, fg.PatternStr, nil)
|
||||||
|
}
|
||||||
|
dbPaths = list
|
||||||
|
d.mutex.Lock()
|
||||||
|
d.dbPaths[name] = dbPaths
|
||||||
|
d.mutex.Unlock()
|
||||||
|
}
|
||||||
|
return dbPaths, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) OpenDB(path string) (*sql.DB, error) {
|
||||||
|
d.mutex.RLock()
|
||||||
|
db, ok := d.dbs[path]
|
||||||
|
d.mutex.RUnlock()
|
||||||
|
if ok {
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
var err error
|
||||||
|
tempPath := path
|
||||||
|
if runtime.GOOS == "windows" {
|
||||||
|
tempPath, err = filecopy.GetTempCopy(path)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msgf("获取临时拷贝文件 %s 失败", path)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
db, err = sql.Open("sqlite3", tempPath)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msgf("连接数据库 %s 失败", path)
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
d.mutex.Lock()
|
||||||
|
d.dbs[path] = db
|
||||||
|
d.mutex.Unlock()
|
||||||
|
return db, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) Callback(event fsnotify.Event) error {
|
||||||
|
if !event.Op.Has(fsnotify.Create) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
d.mutex.Lock()
|
||||||
|
db, ok := d.dbs[event.Name]
|
||||||
|
if ok {
|
||||||
|
delete(d.dbs, event.Name)
|
||||||
|
go func(db *sql.DB) {
|
||||||
|
time.Sleep(time.Second * 5)
|
||||||
|
db.Close()
|
||||||
|
}(db)
|
||||||
|
}
|
||||||
|
d.mutex.Unlock()
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) Start() error {
|
||||||
|
return d.fm.Start()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) Stop() error {
|
||||||
|
return d.fm.Stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (d *DBManager) Close() error {
|
||||||
|
for _, db := range d.dbs {
|
||||||
|
db.Close()
|
||||||
|
}
|
||||||
|
return d.fm.Stop()
|
||||||
|
}
|
||||||
42
internal/wechatdb/datasource/dbm/dbm_test.go
Normal file
42
internal/wechatdb/datasource/dbm/dbm_test.go
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
package dbm
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestXxx(t *testing.T) {
|
||||||
|
path := "/Users/sarv/Documents/chatlog/bigjun_9e7a"
|
||||||
|
|
||||||
|
g := &Group{
|
||||||
|
Name: "session",
|
||||||
|
Pattern: `session\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
}
|
||||||
|
|
||||||
|
d := NewDBManager(path)
|
||||||
|
d.AddGroup(g)
|
||||||
|
d.Start()
|
||||||
|
|
||||||
|
i := 0
|
||||||
|
for {
|
||||||
|
db, err := d.GetDB("session")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
var username string
|
||||||
|
row := db.QueryRow(`SELECT username FROM SessionTable LIMIT 1`)
|
||||||
|
if err := row.Scan(&username); err != nil {
|
||||||
|
fmt.Printf("Error scanning row: %v\n", err)
|
||||||
|
time.Sleep(100 * time.Millisecond)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
fmt.Printf("%d: Username: %s\n", i, username)
|
||||||
|
i++
|
||||||
|
time.Sleep(1000 * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
7
internal/wechatdb/datasource/dbm/group.go
Normal file
7
internal/wechatdb/datasource/dbm/group.go
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
package dbm
|
||||||
|
|
||||||
|
type Group struct {
|
||||||
|
Name string
|
||||||
|
Pattern string
|
||||||
|
BlackList []string
|
||||||
|
}
|
||||||
722
internal/wechatdb/datasource/v4/datasource.go
Normal file
722
internal/wechatdb/datasource/v4/datasource.go
Normal file
@@ -0,0 +1,722 @@
|
|||||||
|
package v4
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"crypto/md5"
|
||||||
|
"database/sql"
|
||||||
|
"encoding/hex"
|
||||||
|
"fmt"
|
||||||
|
"regexp"
|
||||||
|
"sort"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/fsnotify/fsnotify"
|
||||||
|
_ "github.com/mattn/go-sqlite3"
|
||||||
|
"github.com/rs/zerolog/log"
|
||||||
|
|
||||||
|
"github.com/sjzar/chatlog/internal/errors"
|
||||||
|
"github.com/sjzar/chatlog/internal/model"
|
||||||
|
"github.com/sjzar/chatlog/internal/wechatdb/datasource/dbm"
|
||||||
|
"github.com/sjzar/chatlog/pkg/util"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
Message = "message"
|
||||||
|
Contact = "contact"
|
||||||
|
Session = "session"
|
||||||
|
Media = "media"
|
||||||
|
Voice = "voice"
|
||||||
|
)
|
||||||
|
|
||||||
|
var Groups = []*dbm.Group{
|
||||||
|
{
|
||||||
|
Name: Message,
|
||||||
|
Pattern: `^message_([0-9]?[0-9])?\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: Contact,
|
||||||
|
Pattern: `^contact\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: Session,
|
||||||
|
Pattern: `session\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: Media,
|
||||||
|
Pattern: `^hardlink\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: Voice,
|
||||||
|
Pattern: `^media_([0-9]?[0-9])?\.db$`,
|
||||||
|
BlackList: []string{},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// MessageDBInfo 存储消息数据库的信息
|
||||||
|
type MessageDBInfo struct {
|
||||||
|
FilePath string
|
||||||
|
StartTime time.Time
|
||||||
|
EndTime time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type DataSource struct {
|
||||||
|
path string
|
||||||
|
dbm *dbm.DBManager
|
||||||
|
|
||||||
|
// 消息数据库信息
|
||||||
|
messageInfos []MessageDBInfo
|
||||||
|
}
|
||||||
|
|
||||||
|
func New(path string) (*DataSource, error) {
|
||||||
|
|
||||||
|
ds := &DataSource{
|
||||||
|
path: path,
|
||||||
|
dbm: dbm.NewDBManager(path),
|
||||||
|
messageInfos: make([]MessageDBInfo, 0),
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, g := range Groups {
|
||||||
|
ds.dbm.AddGroup(g)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ds.dbm.Start(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ds.initMessageDbs(); err != nil {
|
||||||
|
return nil, errors.DBInitFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
ds.dbm.AddCallback(Message, func(event fsnotify.Event) error {
|
||||||
|
if !event.Op.Has(fsnotify.Create) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err := ds.initMessageDbs(); err != nil {
|
||||||
|
log.Err(err).Msgf("Failed to reinitialize message DBs: %s", event.Name)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
|
||||||
|
return ds, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) SetCallback(name string, callback func(event fsnotify.Event) error) error {
|
||||||
|
if name == "chatroom" {
|
||||||
|
name = Contact
|
||||||
|
}
|
||||||
|
return ds.dbm.AddCallback(name, callback)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) initMessageDbs() error {
|
||||||
|
dbPaths, err := ds.dbm.GetDBPath(Message)
|
||||||
|
if err != nil {
|
||||||
|
if strings.Contains(err.Error(), "db file not found") {
|
||||||
|
ds.messageInfos = make([]MessageDBInfo, 0)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理每个数据库文件
|
||||||
|
infos := make([]MessageDBInfo, 0)
|
||||||
|
for _, filePath := range dbPaths {
|
||||||
|
db, err := ds.dbm.OpenDB(filePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Err(err).Msgf("获取数据库 %s 失败", filePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 获取 Timestamp 表中的开始时间
|
||||||
|
var startTime time.Time
|
||||||
|
var timestamp int64
|
||||||
|
|
||||||
|
row := db.QueryRow("SELECT timestamp FROM Timestamp LIMIT 1")
|
||||||
|
if err := row.Scan(×tamp); err != nil {
|
||||||
|
log.Err(err).Msgf("获取数据库 %s 的时间戳失败", filePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
startTime = time.Unix(timestamp, 0)
|
||||||
|
|
||||||
|
// 保存数据库信息
|
||||||
|
infos = append(infos, MessageDBInfo{
|
||||||
|
FilePath: filePath,
|
||||||
|
StartTime: startTime,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// 按照 StartTime 排序数据库文件
|
||||||
|
sort.Slice(infos, func(i, j int) bool {
|
||||||
|
return infos[i].StartTime.Before(infos[j].StartTime)
|
||||||
|
})
|
||||||
|
|
||||||
|
// 设置结束时间
|
||||||
|
for i := range infos {
|
||||||
|
if i == len(infos)-1 {
|
||||||
|
infos[i].EndTime = time.Now()
|
||||||
|
} else {
|
||||||
|
infos[i].EndTime = infos[i+1].StartTime
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ds.messageInfos = infos
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// getDBInfosForTimeRange 获取时间范围内的数据库信息
|
||||||
|
func (ds *DataSource) getDBInfosForTimeRange(startTime, endTime time.Time) []MessageDBInfo {
|
||||||
|
var dbs []MessageDBInfo
|
||||||
|
for _, info := range ds.messageInfos {
|
||||||
|
if info.StartTime.Before(endTime) && info.EndTime.After(startTime) {
|
||||||
|
dbs = append(dbs, info)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return dbs
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) GetMessages(ctx context.Context, startTime, endTime time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
|
||||||
|
if talker == "" {
|
||||||
|
return nil, errors.ErrTalkerEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析talker参数,支持多个talker(以英文逗号分隔)
|
||||||
|
talkers := util.Str2List(talker, ",")
|
||||||
|
if len(talkers) == 0 {
|
||||||
|
return nil, errors.ErrTalkerEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
// 找到时间范围内的数据库文件
|
||||||
|
dbInfos := ds.getDBInfosForTimeRange(startTime, endTime)
|
||||||
|
if len(dbInfos) == 0 {
|
||||||
|
return nil, errors.TimeRangeNotFound(startTime, endTime)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 解析sender参数,支持多个发送者(以英文逗号分隔)
|
||||||
|
senders := util.Str2List(sender, ",")
|
||||||
|
|
||||||
|
// 预编译正则表达式(如果有keyword)
|
||||||
|
var regex *regexp.Regexp
|
||||||
|
if keyword != "" {
|
||||||
|
var err error
|
||||||
|
regex, err = regexp.Compile(keyword)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed("invalid regex pattern", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 从每个相关数据库中查询消息,并在读取时进行过滤
|
||||||
|
filteredMessages := []*model.Message{}
|
||||||
|
|
||||||
|
for _, dbInfo := range dbInfos {
|
||||||
|
// 检查上下文是否已取消
|
||||||
|
if err := ctx.Err(); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
db, err := ds.dbm.OpenDB(dbInfo.FilePath)
|
||||||
|
if err != nil {
|
||||||
|
log.Error().Msgf("数据库 %s 未打开", dbInfo.FilePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对每个talker进行查询
|
||||||
|
for _, talkerItem := range talkers {
|
||||||
|
// 构建表名
|
||||||
|
_talkerMd5Bytes := md5.Sum([]byte(talkerItem))
|
||||||
|
talkerMd5 := hex.EncodeToString(_talkerMd5Bytes[:])
|
||||||
|
tableName := "Msg_" + talkerMd5
|
||||||
|
|
||||||
|
// 检查表是否存在
|
||||||
|
var exists bool
|
||||||
|
err = db.QueryRowContext(ctx,
|
||||||
|
"SELECT 1 FROM sqlite_master WHERE type='table' AND name=?",
|
||||||
|
tableName).Scan(&exists)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
if err == sql.ErrNoRows {
|
||||||
|
// 表不存在,继续下一个talker
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
return nil, errors.QueryFailed("", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 构建查询条件
|
||||||
|
conditions := []string{"create_time >= ? AND create_time <= ?"}
|
||||||
|
args := []interface{}{startTime.Unix(), endTime.Unix()}
|
||||||
|
log.Debug().Msgf("Table name: %s", tableName)
|
||||||
|
log.Debug().Msgf("Start time: %d, End time: %d", startTime.Unix(), endTime.Unix())
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT m.sort_seq, m.server_id, m.local_type, n.user_name, m.create_time, m.message_content, m.packed_info_data, m.status
|
||||||
|
FROM %s m
|
||||||
|
LEFT JOIN Name2Id n ON m.real_sender_id = n.rowid
|
||||||
|
WHERE %s
|
||||||
|
ORDER BY m.sort_seq ASC
|
||||||
|
`, tableName, strings.Join(conditions, " AND "))
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
// 如果表不存在,SQLite 会返回错误
|
||||||
|
if strings.Contains(err.Error(), "no such table") {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
log.Err(err).Msgf("从数据库 %s 查询消息失败", dbInfo.FilePath)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理查询结果,在读取时进行过滤
|
||||||
|
for rows.Next() {
|
||||||
|
var msg model.MessageV4
|
||||||
|
err := rows.Scan(
|
||||||
|
&msg.SortSeq,
|
||||||
|
&msg.ServerID,
|
||||||
|
&msg.LocalType,
|
||||||
|
&msg.UserName,
|
||||||
|
&msg.CreateTime,
|
||||||
|
&msg.MessageContent,
|
||||||
|
&msg.PackedInfoData,
|
||||||
|
&msg.Status,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
rows.Close()
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 将消息转换为标准格式
|
||||||
|
message := msg.Wrap(talkerItem)
|
||||||
|
|
||||||
|
// 应用sender过滤
|
||||||
|
if len(senders) > 0 {
|
||||||
|
senderMatch := false
|
||||||
|
for _, s := range senders {
|
||||||
|
if message.Sender == s {
|
||||||
|
senderMatch = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !senderMatch {
|
||||||
|
continue // 不匹配sender,跳过此消息
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 应用keyword过滤
|
||||||
|
if regex != nil {
|
||||||
|
plainText := message.PlainTextContent()
|
||||||
|
if !regex.MatchString(plainText) {
|
||||||
|
continue // 不匹配keyword,跳过此消息
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 通过所有过滤条件,保留此消息
|
||||||
|
filteredMessages = append(filteredMessages, message)
|
||||||
|
|
||||||
|
// 检查是否已经满足分页处理数量
|
||||||
|
if limit > 0 && len(filteredMessages) >= offset+limit {
|
||||||
|
// 已经获取了足够的消息,可以提前返回
|
||||||
|
rows.Close()
|
||||||
|
|
||||||
|
// 对所有消息按时间排序
|
||||||
|
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||||
|
return filteredMessages[i].Seq < filteredMessages[j].Seq
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理分页
|
||||||
|
if offset >= len(filteredMessages) {
|
||||||
|
return []*model.Message{}, nil
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(filteredMessages) {
|
||||||
|
end = len(filteredMessages)
|
||||||
|
}
|
||||||
|
return filteredMessages[offset:end], nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 对所有消息按时间排序
|
||||||
|
sort.Slice(filteredMessages, func(i, j int) bool {
|
||||||
|
return filteredMessages[i].Seq < filteredMessages[j].Seq
|
||||||
|
})
|
||||||
|
|
||||||
|
// 处理分页
|
||||||
|
if limit > 0 {
|
||||||
|
if offset >= len(filteredMessages) {
|
||||||
|
return []*model.Message{}, nil
|
||||||
|
}
|
||||||
|
end := offset + limit
|
||||||
|
if end > len(filteredMessages) {
|
||||||
|
end = len(filteredMessages)
|
||||||
|
}
|
||||||
|
return filteredMessages[offset:end], nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return filteredMessages, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 联系人
|
||||||
|
func (ds *DataSource) GetContacts(ctx context.Context, key string, limit, offset int) ([]*model.Contact, error) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
// 按照关键字查询
|
||||||
|
query = `SELECT username, local_type, alias, remark, nick_name
|
||||||
|
FROM contact
|
||||||
|
WHERE username = ? OR alias = ? OR remark = ? OR nick_name = ?`
|
||||||
|
args = []interface{}{key, key, key, key}
|
||||||
|
} else {
|
||||||
|
// 查询所有联系人
|
||||||
|
query = `SELECT username, local_type, alias, remark, nick_name FROM contact`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加排序、分页
|
||||||
|
query += ` ORDER BY username`
|
||||||
|
if limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||||
|
if offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
db, err := ds.dbm.GetDB(Contact)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
contacts := []*model.Contact{}
|
||||||
|
for rows.Next() {
|
||||||
|
var contactV4 model.ContactV4
|
||||||
|
err := rows.Scan(
|
||||||
|
&contactV4.UserName,
|
||||||
|
&contactV4.LocalType,
|
||||||
|
&contactV4.Alias,
|
||||||
|
&contactV4.Remark,
|
||||||
|
&contactV4.NickName,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
contacts = append(contacts, contactV4.Wrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
return contacts, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// 群聊
|
||||||
|
func (ds *DataSource) GetChatRooms(ctx context.Context, key string, limit, offset int) ([]*model.ChatRoom, error) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
db, err := ds.dbm.GetDB(Contact)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
// 按照关键字查询
|
||||||
|
query = `SELECT username, owner, ext_buffer FROM chat_room WHERE username = ?`
|
||||||
|
args = []interface{}{key}
|
||||||
|
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
chatRooms := []*model.ChatRoom{}
|
||||||
|
for rows.Next() {
|
||||||
|
var chatRoomV4 model.ChatRoomV4
|
||||||
|
err := rows.Scan(
|
||||||
|
&chatRoomV4.UserName,
|
||||||
|
&chatRoomV4.Owner,
|
||||||
|
&chatRoomV4.ExtBuffer,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRooms = append(chatRooms, chatRoomV4.Wrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到群聊,尝试通过联系人查找
|
||||||
|
if len(chatRooms) == 0 {
|
||||||
|
contacts, err := ds.GetContacts(ctx, key, 1, 0)
|
||||||
|
if err == nil && len(contacts) > 0 && strings.HasSuffix(contacts[0].UserName, "@chatroom") {
|
||||||
|
// 再次尝试通过用户名查找群聊
|
||||||
|
rows, err := db.QueryContext(ctx,
|
||||||
|
`SELECT username, owner, ext_buffer FROM chat_room WHERE username = ?`,
|
||||||
|
contacts[0].UserName)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var chatRoomV4 model.ChatRoomV4
|
||||||
|
err := rows.Scan(
|
||||||
|
&chatRoomV4.UserName,
|
||||||
|
&chatRoomV4.Owner,
|
||||||
|
&chatRoomV4.ExtBuffer,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRooms = append(chatRooms, chatRoomV4.Wrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果群聊记录不存在,但联系人记录存在,创建一个模拟的群聊对象
|
||||||
|
if len(chatRooms) == 0 {
|
||||||
|
chatRooms = append(chatRooms, &model.ChatRoom{
|
||||||
|
Name: contacts[0].UserName,
|
||||||
|
Users: make([]model.ChatRoomUser, 0),
|
||||||
|
User2DisplayName: make(map[string]string),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return chatRooms, nil
|
||||||
|
} else {
|
||||||
|
// 查询所有群聊
|
||||||
|
query = `SELECT username, owner, ext_buffer FROM chat_room`
|
||||||
|
|
||||||
|
// 添加排序、分页
|
||||||
|
query += ` ORDER BY username`
|
||||||
|
if limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||||
|
if offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
chatRooms := []*model.ChatRoom{}
|
||||||
|
for rows.Next() {
|
||||||
|
var chatRoomV4 model.ChatRoomV4
|
||||||
|
err := rows.Scan(
|
||||||
|
&chatRoomV4.UserName,
|
||||||
|
&chatRoomV4.Owner,
|
||||||
|
&chatRoomV4.ExtBuffer,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
chatRooms = append(chatRooms, chatRoomV4.Wrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
return chatRooms, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 最近会话
|
||||||
|
func (ds *DataSource) GetSessions(ctx context.Context, key string, limit, offset int) ([]*model.Session, error) {
|
||||||
|
var query string
|
||||||
|
var args []interface{}
|
||||||
|
|
||||||
|
if key != "" {
|
||||||
|
// 按照关键字查询
|
||||||
|
query = `SELECT username, summary, last_timestamp, last_msg_sender, last_sender_display_name
|
||||||
|
FROM SessionTable
|
||||||
|
WHERE username = ? OR last_sender_display_name = ?
|
||||||
|
ORDER BY sort_timestamp DESC`
|
||||||
|
args = []interface{}{key, key}
|
||||||
|
} else {
|
||||||
|
// 查询所有会话
|
||||||
|
query = `SELECT username, summary, last_timestamp, last_msg_sender, last_sender_display_name
|
||||||
|
FROM SessionTable
|
||||||
|
ORDER BY sort_timestamp DESC`
|
||||||
|
}
|
||||||
|
|
||||||
|
// 添加分页
|
||||||
|
if limit > 0 {
|
||||||
|
query += fmt.Sprintf(" LIMIT %d", limit)
|
||||||
|
if offset > 0 {
|
||||||
|
query += fmt.Sprintf(" OFFSET %d", offset)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
db, err := ds.dbm.GetDB(Session)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
sessions := []*model.Session{}
|
||||||
|
for rows.Next() {
|
||||||
|
var sessionV4 model.SessionV4
|
||||||
|
err := rows.Scan(
|
||||||
|
&sessionV4.Username,
|
||||||
|
&sessionV4.Summary,
|
||||||
|
&sessionV4.LastTimestamp,
|
||||||
|
&sessionV4.LastMsgSender,
|
||||||
|
&sessionV4.LastSenderDisplayName,
|
||||||
|
)
|
||||||
|
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sessions = append(sessions, sessionV4.Wrap())
|
||||||
|
}
|
||||||
|
|
||||||
|
return sessions, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) GetMedia(ctx context.Context, _type string, key string) (*model.Media, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, errors.ErrKeyEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
var table string
|
||||||
|
switch _type {
|
||||||
|
case "image":
|
||||||
|
table = "image_hardlink_info_v3"
|
||||||
|
case "video":
|
||||||
|
table = "video_hardlink_info_v3"
|
||||||
|
case "file":
|
||||||
|
table = "file_hardlink_info_v3"
|
||||||
|
case "voice":
|
||||||
|
return ds.GetVoice(ctx, key)
|
||||||
|
default:
|
||||||
|
return nil, errors.MediaTypeUnsupported(_type)
|
||||||
|
}
|
||||||
|
|
||||||
|
query := fmt.Sprintf(`
|
||||||
|
SELECT
|
||||||
|
f.md5,
|
||||||
|
f.file_name,
|
||||||
|
f.file_size,
|
||||||
|
f.modify_time,
|
||||||
|
IFNULL(d1.username,""),
|
||||||
|
IFNULL(d2.username,"")
|
||||||
|
FROM
|
||||||
|
%s f
|
||||||
|
LEFT JOIN
|
||||||
|
dir2id d1 ON d1.rowid = f.dir1
|
||||||
|
LEFT JOIN
|
||||||
|
dir2id d2 ON d2.rowid = f.dir2
|
||||||
|
`, table)
|
||||||
|
query += " WHERE f.md5 = ? OR f.file_name LIKE ? || '%'"
|
||||||
|
args := []interface{}{key, key}
|
||||||
|
|
||||||
|
// 执行查询
|
||||||
|
db, err := ds.dbm.GetDB(Media)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var media *model.Media
|
||||||
|
for rows.Next() {
|
||||||
|
var mediaV4 model.MediaV4
|
||||||
|
err := rows.Scan(
|
||||||
|
&mediaV4.Key,
|
||||||
|
&mediaV4.Name,
|
||||||
|
&mediaV4.Size,
|
||||||
|
&mediaV4.ModifyTime,
|
||||||
|
&mediaV4.Dir1,
|
||||||
|
&mediaV4.Dir2,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
mediaV4.Type = _type
|
||||||
|
media = mediaV4.Wrap()
|
||||||
|
|
||||||
|
// 跳过缩略图
|
||||||
|
if _type == "image" && !strings.Contains(media.Name, "_t") {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if media == nil {
|
||||||
|
return nil, errors.ErrMediaNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
return media, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) GetVoice(ctx context.Context, key string) (*model.Media, error) {
|
||||||
|
if key == "" {
|
||||||
|
return nil, errors.ErrKeyEmpty
|
||||||
|
}
|
||||||
|
|
||||||
|
query := `
|
||||||
|
SELECT voice_data
|
||||||
|
FROM VoiceInfo
|
||||||
|
WHERE svr_id = ?
|
||||||
|
`
|
||||||
|
args := []interface{}{key}
|
||||||
|
|
||||||
|
dbs, err := ds.dbm.GetDBs(Voice)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.DBConnectFailed("", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, db := range dbs {
|
||||||
|
rows, err := db.QueryContext(ctx, query, args...)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.QueryFailed(query, err)
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
for rows.Next() {
|
||||||
|
var voiceData []byte
|
||||||
|
err := rows.Scan(
|
||||||
|
&voiceData,
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
return nil, errors.ScanRowFailed(err)
|
||||||
|
}
|
||||||
|
if len(voiceData) > 0 {
|
||||||
|
return &model.Media{
|
||||||
|
Type: "voice",
|
||||||
|
Key: key,
|
||||||
|
Data: voiceData,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil, errors.ErrMediaNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
func (ds *DataSource) Close() error {
|
||||||
|
return ds.dbm.Close()
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user