```
├── .github/
├── workflows/
├── release.yml
├── .gitignore
├── .goreleaser.yaml
├── DISCLAIMER.md
├── LICENSE
├── Makefile
├── README.md
├── cmd/
├── chatlog/
├── cmd_decrypt.go
├── cmd_dumpmemory.go
├── cmd_key.go
├── cmd_server.go
├── cmd_version.go
├── log.go
├── root.go
├── docs/
├── mcp.md
├── prompt.md
├── go.mod
├── go.sum
├── internal/
├── chatlog/
├── app.go
├── conf/
├── config.go
├── service.go
├── ctx/
├── context.go
├── database/
├── service.go
├── http/
├── route.go
├── service.go
├── static/
├── index.htm
├── manager.go
├── mcp/
├── const.go
├── service.go
├── wechat/
├── service.go
├── errors/
├── errors.go
├── http_errors.go
├── middleware.go
├── os_errors.go
├── wechat_errors.go
├── wechatdb_errors.go
├── mcp/
├── error.go
├── initialize.go
├── jsonrpc.go
├── mcp.go
├── prompt.go
├── resource.go
├── session.go
├── sse.go
├── stdio.go
├── tool.go
├── model/
├── chatroom.go
├── chatroom_darwinv3.go
├── chatroom_v4.go
├── contact.go
├── contact_darwinv3.go
├── contact_v4.go
├── media.go
├── media_darwinv3.go
├── media_v4.go
├── mediamessage.go
├── message.go
├── message_darwinv3.go
├── message_v3.go
├── message_v4.go
├── session.go
├── session_darwinv3.go
├── session_v4.go
├── wxproto/
├── bytesextra.pb.go
├── bytesextra.proto
├── packedinfo.pb.go
├── packedinfo.proto
├── roomdata.pb.go
```
## /.github/workflows/release.yml
```yml path="/.github/workflows/release.yml"
name: Release
on:
push:
tags:
- 'v*'
env:
ENABLE_UPX: 1
jobs:
release:
name: Release Binary
runs-on: ubuntu-latest
container:
image: goreleaser/goreleaser-cross:v1.24
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- run: git config --global --add safe.directory "$(pwd)"
- name: Setup Go
uses: actions/setup-go@v4
with:
go-version: '^1.24'
- name: Cache go module
uses: actions/cache@v3
with:
path: ~/go/pkg/mod
key: ${{ runner.os }}-go-${{ hashFiles('**/go.sum') }}
restore-keys: |
${{ runner.os }}-go-
- name: Install UPX
uses: crazy-max/ghaction-upx@v3
with:
install-only: true
- name: Run GoReleaser
run: goreleaser release --clean
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
ENABLE_UPX: true
```
## /.gitignore
```gitignore path="/.gitignore"
# If you prefer the allow list template instead of the deny list, see community template:
# https://github.com/github/gitignore/blob/main/community/Golang/Go.AllowList.gitignore
#
# Binaries for programs and plugins
*.exe
*.exe~
*.dll
*.so
*.dylib
# Test binary, built with `go test -c`
*.test
# Output of the go coverage tool, specifically when used with LiteIDE
*.out
# Dependency directories (remove the comment below to include it)
# vendor/
# Go workspace file
go.work
go.work.sum
# env file
.env
# syncthing files
.stfolder
chatlog.exe# Added by goreleaser init:
dist/
```
## /.goreleaser.yaml
```yaml path="/.goreleaser.yaml"
# 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
```
## /DISCLAIMER.md
# 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. 完整协议
本免责声明构成用户与开发者之间关于本项目使用的完整协议,取代先前或同时期关于本项目的所有口头或书面协议、提议和陈述。本声明的任何豁免、修改或补充均应以书面形式作出并经开发者签署方为有效。
## /LICENSE
``` path="/LICENSE"
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
```
## /Makefile
``` path="/Makefile"
BINARY_NAME := chatlog
GO := go
ifeq ($(VERSION),)
VERSION := $(shell git describe --tags --always --dirty="-dev")
endif
LDFLAGS := -ldflags '-X "github.com/sjzar/chatlog/pkg/version.Version=$(VERSION)" -w -s'
PLATFORMS := \
darwin/amd64 \
darwin/arm64 \
windows/amd64 \
windows/arm64
UPX_PLATFORMS := \
darwin/amd64 \
windows/386 \
windows/amd64
.PHONY: all clean lint tidy test build crossbuild upx
all: clean lint tidy test build
clean:
@echo "🧹 Cleaning..."
@rm -rf bin/
lint:
@echo "🕵️♂️ Running linters..."
golangci-lint run ./...
tidy:
@echo "🧼 Tidying up dependencies..."
$(GO) mod tidy
test:
@echo "🧪 Running tests..."
$(GO) test ./... -cover
build:
@echo "🔨 Building for current platform..."
CGO_ENABLED=1 $(GO) build -trimpath $(LDFLAGS) -o bin/$(BINARY_NAME) main.go
crossbuild: clean
@echo "🌍 Building for multiple platforms..."
for platform in $(PLATFORMS); do \
os=$$(echo $$platform | cut -d/ -f1); \
arch=$$(echo $$platform | cut -d/ -f2); \
float=$$(echo $$platform | cut -d/ -f3); \
output_name=bin/chatlog_$${os}_$${arch}; \
[ "$$float" != "" ] && output_name=$$output_name_$$float; \
echo "🔨 Building for $$os/$$arch..."; \
echo "🔨 Building for $$output_name..."; \
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 \
echo "⚙️ Compressing binary $$output_name..." && upx --best $$output_name; \
fi; \
done
```
## /README.md
# Chatlog

_聊天记录工具,帮助大家轻松使用自己的聊天数据_
[](https://goreportcard.com/report/github.com/sjzar/chatlog)
[](https://godoc.org/github.com/sjzar/chatlog)
[](https://github.com/sjzar/chatlog/releases)
[](https://github.com/sjzar/chatlog/blob/main/LICENSE)

## Feature
- 从本地数据库文件获取聊天数据
- 支持 Windows / macOS 系统
- 支持微信 3.x / 4.0 版本
- 提供 Terminal UI 界面 & 命令行工具
- 提供 HTTP API 服务,支持查询聊天记录、联系人、群聊、最近会话等信息
- 支持 MCP SSE 协议,可与支持 MCP 的 AI 助手无缝集成
- 支持多媒体消息,支持解密图片、语音
- 支持自动解密数据,简化使用流程
- 支持多账号管理,可在不同账号间切换
## TODO
- 聊天数据全文索引
- 聊天数据统计 & Dashboard
## 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-集成)
## 安装指南
### 从源码安装
```bash
go install github.com/sjzar/chatlog@latest
```
### 下载预编译版本
访问 [Releases](https://github.com/sjzar/chatlog/releases) 页面下载适合您系统的预编译版本。
## 使用指南
### Terminal UI 模式
最简单的使用方式是通过 Terminal UI 界面操作:
```bash
chatlog
```
操作方法:
- 使用 `↑` `↓` 键选择菜单项
- 按 `Enter` 确认选择
- 按 `Esc` 返回上级菜单
- 按 `Ctrl+C` 退出程序
### 命令行模式
对于熟悉命令行的用户,可以直接使用以下命令:
```bash
# 获取微信数据密钥
chatlog key
# 解密数据库文件
chatlog decrypt
# 启动 HTTP 服务
chatlog server
```
### 从手机迁移聊天记录
如果电脑端微信聊天记录不全,可以从手机端迁移数据:
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 服务后(默认地址 `http://127.0.0.1:5030`),可通过以下 API 访问数据:
### 聊天记录查询
```
GET /api/v1/chatlog?time=2023-01-01&talker=wxid_xxx
```
参数说明:
- `time`: 时间范围,格式为 `YYYY-MM-DD` 或 `YYYY-MM-DD~YYYY-MM-DD`
- `talker`: 聊天对象标识(支持 wxid、群聊 ID、备注名、昵称等)
- `limit`: 返回记录数量
- `offset`: 分页偏移量
- `format`: 输出格式,支持 `json`、`csv` 或纯文本
### 其他 API 接口
- **联系人列表**:`GET /api/v1/contact`
- **群聊列表**:`GET /api/v1/chatroom`
- **会话列表**:`GET /api/v1/session`
### 多媒体内容
聊天记录中的多媒体内容会通过 HTTP 服务进行提供,可通过以下路径访问:
- **图片内容**:`GET /image/`
- **视频内容**:`GET /video/`
- **文件内容**:`GET /file/`
- **语音内容**:`GET /voice/`
- **多媒体内容**:`GET /data/`
当请求图片、视频、文件内容时,将返回 302 跳转到多媒体内容 URL。
当请求语音内容时,将直接返回语音内容,并对原始 SILK 语音做了实时转码 MP3 处理。
多媒体内容 URL 地址为基于`数据目录`的相对地址,请求多媒体内容将直接返回对应文件,并针对加密图片做了实时解密处理。
## MCP 集成
Chatlog 支持 MCP (Model Context Protocol) SSE 协议,可与支持 MCP 的 AI 助手无缝集成。
启动 HTTP 服务后,通过 SSE Endpoint 访问服务:
```
GET /sse
```
### 快速集成
Chatlog 可以与多种支持 MCP 的 AI 助手集成,包括:
- **ChatWise**: 直接支持 SSE,在工具设置中添加 `http://127.0.0.1:5030/sse`
- **Cherry Studio**: 直接支持 SSE,在 MCP 服务器设置中添加 `http://127.0.0.1:5030/sse`
对于不直接支持 SSE 的客户端,可以使用 [mcp-proxy](https://github.com/sparfenyuk/mcp-proxy) 工具转发请求:
- **Claude Desktop**: 通过 mcp-proxy 支持,需要配置 `claude_desktop_config.json`
- **Monica Code**: 通过 mcp-proxy 支持,需要配置 VSCode 插件设置
### 详细集成指南
查看 [MCP 集成指南](docs/mcp.md) 获取各平台的详细配置步骤和注意事项。
## Prompt 示例
为了帮助大家更好地利用 Chatlog 与 AI 助手,我们整理了一些 prompt 示例。希望这些 prompt 可以启发大家更有效地查询和分析聊天记录,获取更精准的信息。
查看 [Prompt 指南](docs/prompt.md) 获取详细示例。
同时欢迎大家分享使用经验和 prompt!如果您有好的 prompt 示例或使用技巧,请通过 [Discussions](https://github.com/sjzar/chatlog/discussions) 进行分享,共同进步。
## 免责声明
⚠️ **重要提示:使用本项目前,请务必阅读并理解完整的 [免责声明](./DISCLAIMER.md)。**
本项目仅供学习、研究和个人合法使用,禁止用于任何非法目的或未授权访问他人数据。下载、安装或使用本工具即表示您同意遵守免责声明中的所有条款,并自行承担使用过程中的全部风险和法律责任。
### 摘要(请阅读完整免责声明)
- 仅限处理您自己合法拥有的聊天数据或已获授权的数据
- 严禁用于未经授权获取、查看或分析他人聊天记录
- 开发者不对使用本工具可能导致的任何损失承担责任
- 使用第三方 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 开源库的贡献者们
## /cmd/chatlog/cmd_decrypt.go
```go path="/cmd/chatlog/cmd_decrypt.go"
package chatlog
import (
"fmt"
"runtime"
"github.com/sjzar/chatlog/internal/chatlog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(decryptCmd)
decryptCmd.Flags().StringVarP(&dataDir, "data-dir", "d", "", "data dir")
decryptCmd.Flags().StringVarP(&workDir, "work-dir", "w", "", "work dir")
decryptCmd.Flags().StringVarP(&key, "key", "k", "", "key")
decryptCmd.Flags().StringVarP(&decryptPlatform, "platform", "p", runtime.GOOS, "platform")
decryptCmd.Flags().IntVarP(&decryptVer, "version", "v", 3, "version")
}
var (
dataDir string
workDir string
key string
decryptPlatform string
decryptVer int
)
var decryptCmd = &cobra.Command{
Use: "decrypt",
Short: "decrypt",
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.CommandDecrypt(dataDir, workDir, key, decryptPlatform, decryptVer); err != nil {
log.Err(err).Msg("failed to decrypt")
return
}
fmt.Println("decrypt success")
},
}
```
## /cmd/chatlog/cmd_dumpmemory.go
```go path="/cmd/chatlog/cmd_dumpmemory.go"
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)
},
}
```
## /cmd/chatlog/cmd_key.go
```go path="/cmd/chatlog/cmd_key.go"
package chatlog
import (
"fmt"
"github.com/sjzar/chatlog/internal/chatlog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(keyCmd)
keyCmd.Flags().IntVarP(&pid, "pid", "p", 0, "pid")
}
var pid int
var keyCmd = &cobra.Command{
Use: "key",
Short: "key",
Run: func(cmd *cobra.Command, args []string) {
m, err := chatlog.New("")
if err != nil {
log.Err(err).Msg("failed to create chatlog instance")
return
}
ret, err := m.CommandKey(pid)
if err != nil {
log.Err(err).Msg("failed to get key")
return
}
fmt.Println(ret)
},
}
```
## /cmd/chatlog/cmd_server.go
```go path="/cmd/chatlog/cmd_server.go"
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
}
},
}
```
## /cmd/chatlog/cmd_version.go
```go path="/cmd/chatlog/cmd_version.go"
package chatlog
import (
"fmt"
"github.com/sjzar/chatlog/pkg/version"
"github.com/spf13/cobra"
)
func init() {
rootCmd.AddCommand(versionCmd)
versionCmd.Flags().BoolVarP(&versionM, "module", "m", false, "module version information")
}
var versionM bool
var versionCmd = &cobra.Command{
Use: "version [-m]",
Short: "Show the version of chatlog",
Run: func(cmd *cobra.Command, args []string) {
if versionM {
fmt.Println(version.GetMore(true))
} else {
fmt.Printf("chatlog %s\n", version.GetMore(false))
}
},
}
```
## /cmd/chatlog/log.go
```go path="/cmd/chatlog/log.go"
package chatlog
import (
"io"
"os"
"path/filepath"
"time"
"github.com/sjzar/chatlog/pkg/util"
"github.com/rs/zerolog"
"github.com/rs/zerolog/log"
"github.com/sirupsen/logrus"
"github.com/spf13/cobra"
)
var Debug bool
func initLog(cmd *cobra.Command, args []string) {
zerolog.SetGlobalLevel(zerolog.InfoLevel)
if Debug {
zerolog.SetGlobalLevel(zerolog.DebugLevel)
}
log.Logger = log.Output(zerolog.ConsoleWriter{Out: os.Stderr, TimeFormat: time.RFC3339})
}
func initTuiLog(cmd *cobra.Command, args []string) {
logOutput := io.Discard
debug, _ := cmd.Flags().GetBool("debug")
if debug {
logpath := util.DefaultWorkDir("")
util.PrepareDir(logpath)
logFD, err := os.OpenFile(filepath.Join(logpath, "chatlog.log"), os.O_CREATE|os.O_WRONLY|os.O_APPEND, os.ModePerm)
if err != nil {
panic(err)
}
logOutput = logFD
}
log.Logger = log.Output(zerolog.ConsoleWriter{Out: logOutput, NoColor: true, TimeFormat: time.RFC3339})
logrus.SetOutput(logOutput)
}
```
## /cmd/chatlog/root.go
```go path="/cmd/chatlog/root.go"
package chatlog
import (
"github.com/sjzar/chatlog/internal/chatlog"
"github.com/rs/zerolog/log"
"github.com/spf13/cobra"
)
func init() {
// windows only
cobra.MousetrapHelpText = ""
rootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "debug")
rootCmd.PersistentPreRun = initLog
}
func Execute() {
if err := rootCmd.Execute(); err != nil {
log.Err(err).Msg("command execution failed")
}
}
var rootCmd = &cobra.Command{
Use: "chatlog",
Short: "chatlog",
Long: `chatlog`,
Example: `chatlog`,
Args: cobra.MinimumNArgs(0),
CompletionOptions: cobra.CompletionOptions{
HiddenDefaultCmd: true,
},
PreRun: initTuiLog,
Run: Root,
}
func Root(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.Run(); err != nil {
log.Err(err).Msg("failed to run chatlog instance")
}
}
```
## /docs/mcp.md
# 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. 测试功能是否正常

## /docs/prompt.md
# Prompt 指南
## 概述
优秀的 `prompt` 可以极大的提高 `chatlog` 使用体验,收集了部分群友分享的 `prompt`,供大家参考。
在处理聊天记录时,尽量选择上下文长度足够的 LLM,例如 `Gemini 2.5 Pro`、`Claude 3.5 Sonnet` 等。
欢迎大家在 [Discussions](https://github.com/sjzar/chatlog/discussions/47) 中分享自己的使用方式,共同进步。
## 群聊总结
作者:@eyaeya
```md
你是一个中文的群聊总结的助手,你可以为一个微信的群聊记录,提取并总结每个时间段大家在重点讨论的话题内容。
请帮我将 "" 在 的群聊内容总结成一个群聊报告,包含不多于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. 额外要求(严格执行):
- 如果有多个相关问题,保持逻辑顺序
- 标记重要的警告和建议、突出经验性的分享内容、保留有价值的专业术语解释、移除"我来分析"等过渡语确保链接的完整性
- 直接以日期开始,不要添加任何开场白
```
## /go.mod
```mod path="/go.mod"
module github.com/sjzar/chatlog
go 1.24.0
require (
github.com/gdamore/tcell/v2 v2.8.1
github.com/gin-gonic/gin v1.10.0
github.com/google/uuid v1.6.0
github.com/klauspost/compress v1.18.0
github.com/mattn/go-sqlite3 v1.14.27
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/sjzar/go-lame v0.0.8
github.com/sjzar/go-silk v0.0.1
github.com/spf13/cobra v1.9.1
github.com/spf13/viper v1.20.1
golang.org/x/crypto v0.37.0
golang.org/x/sys v0.32.0
google.golang.org/protobuf v1.36.6
howett.net/plist v1.0.1
)
require (
github.com/bytedance/sonic v1.13.2 // indirect
github.com/bytedance/sonic/loader v0.2.4 // indirect
github.com/cloudwego/base64x v0.1.5 // indirect
github.com/ebitengine/purego v0.8.2 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/gabriel-vasile/mimetype v1.4.8 // indirect
github.com/gdamore/encoding v1.0.1 // indirect
github.com/gin-contrib/sse v1.1.0 // indirect
github.com/go-ole/go-ole v1.3.0 // indirect
github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.26.0 // indirect
github.com/go-viper/mapstructure/v2 v2.2.1 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // indirect
github.com/json-iterator/go v1.1.12 // indirect
github.com/klauspost/cpuid/v2 v2.2.10 // indirect
github.com/leodido/go-urn v1.4.0 // indirect
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
github.com/modern-go/reflect2 v1.0.2 // indirect
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/rivo/uniseg v0.4.7 // indirect
github.com/sagikazarmark/locafero v0.9.0 // indirect
github.com/sourcegraph/conc v0.3.0 // indirect
github.com/spf13/afero v1.14.0 // indirect
github.com/spf13/cast v1.7.1 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/subosito/gotenv v1.6.0 // indirect
github.com/tklauser/go-sysconf v0.3.15 // indirect
github.com/tklauser/numcpus v0.10.0 // indirect
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.12 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.uber.org/multierr v1.11.0 // indirect
golang.org/x/arch v0.16.0 // indirect
golang.org/x/net v0.39.0 // indirect
golang.org/x/term v0.31.0 // indirect
golang.org/x/text v0.24.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
```
## /go.sum
```sum path="/go.sum"
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
github.com/bytedance/sonic v1.13.2/go.mod h1:o68xyaF9u2gvVBuGHPlUVCy+ZfmNNO5ETf1+KgkJhz4=
github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU=
github.com/bytedance/sonic/loader v0.2.4 h1:ZWCw4stuXUsn1/+zQDqeE7JKP+QO47tz7QCNan80NzY=
github.com/bytedance/sonic/loader v0.2.4/go.mod h1:N8A3vUdtUebEY2/VQC0MyhYeKUFosQU6FxH2JmUe6VI=
github.com/cloudwego/base64x v0.1.5 h1:XPciSp1xaq2VCSt6lF0phncD4koWyULpl5bUxbfCyP4=
github.com/cloudwego/base64x v0.1.5/go.mod h1:0zlkT4Wn5C6NdauXdJRhSKRlJvmclQ1hhJgA0rcu/8w=
github.com/cloudwego/iasm v0.2.0/go.mod h1:8rXZaNYT2n95jn+zTI1sDr+IgcD2GVs0nlbbQPiEFhY=
github.com/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/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/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I=
github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ=
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/gabriel-vasile/mimetype v1.4.8 h1:FfZ3gj38NjllZIeJAmMhr+qKL8Wu+nOoI3GqacKw1NM=
github.com/gabriel-vasile/mimetype v1.4.8/go.mod h1:ByKUIKGjh1ODkGM1asKUbQZOLGrPjydw3hYPU2YU9t8=
github.com/gdamore/encoding v1.0.1 h1:YzKZckdBL6jVt2Gc+5p82qhrGiqMdG/eNs6Wy0u3Uhw=
github.com/gdamore/encoding v1.0.1/go.mod h1:0Z0cMFinngz9kS1QfMjCP8TY7em3bZYeeklsSDPivEo=
github.com/gdamore/tcell/v2 v2.8.1 h1:KPNxyqclpWpWQlPLx6Xui1pMk8S+7+R37h3g07997NU=
github.com/gdamore/tcell/v2 v2.8.1/go.mod h1:bj8ori1BG3OYMjmb3IklZVWfZUJ1UBQt9JXrOCOhGWw=
github.com/gin-contrib/sse v1.1.0 h1:n0w2GMuUpWDVp7qSpvze6fAu9iRxJY4Hmj6AmBOU05w=
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/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.3.0 h1:Dt6ye7+vXGIKZ7Xtk4s6/xVdGDQynvom7xCFEdWr6uE=
github.com/go-ole/go-ole v1.3.0/go.mod h1:5LS6F96DhAwUc7C+1HLexzMXY1xGRSryjyPPKW6zv78=
github.com/go-playground/assert/v2 v2.2.0 h1:JvknZsQTYeFEAhQwI4qEt9cyV5ONwRHC+lYKSsYSR8s=
github.com/go-playground/assert/v2 v2.2.0/go.mod h1:VDjEfimB/XKnb+ZQfWdccd7VUvScMdVu0Titje2rxJ4=
github.com/go-playground/locales v0.14.1 h1:EWaQ/wswjilfKLTECiXz7Rh+3BjFhfDFKv/oXslEjJA=
github.com/go-playground/locales v0.14.1/go.mod h1:hxrqLVvrK65+Rwrd5Fc6F2O76J/NuW9t0sjnWqG1slY=
github.com/go-playground/universal-translator v0.18.1 h1:Bcnm0ZwsGyWbCzImXv+pAJnYK9S473LQFuzCbDbfSFY=
github.com/go-playground/universal-translator v0.18.1/go.mod h1:xekY+UJKNuX9WP91TpwSH2VMlDf28Uj24BCp08ZFTUY=
github.com/go-playground/validator/v10 v10.26.0 h1:SP05Nqhjcvz81uJaRfEV0YBSSSGMc/iMaVtFbr3Sw2k=
github.com/go-playground/validator/v10 v10.26.0/go.mod h1:I5QpIEbmr8On7W0TktmJAumgzX4CA1XNl4ZmDuVHKKo=
github.com/go-viper/mapstructure/v2 v2.2.1 h1:ZAaOCxANMuZx5RCeg0mBdEZk7DZasvvZIxtHqx8aGss=
github.com/go-viper/mapstructure/v2 v2.2.1/go.mod h1:oJDH3BJKyqBA2TXFhDsKDGDTlndYOZ6rGS0BRZIxGhM=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/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.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU=
github.com/google/gofuzz v1.0.0/go.mod h1:dBl0BpW6vV/+mYPU4Po3pmUjxk6FQPldtuIdl/M65Eg=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI=
github.com/json-iterator/go v1.1.12 h1:PV8peI4a0ysnczrg+LtxykD8LfKY9ML6u2jnxaEnrnM=
github.com/json-iterator/go v1.1.12/go.mod h1:e30LSqwooZae/UwlEbR2852Gd8hjQvJoHmT4TnhNGBo=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.9/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.10 h1:tBs3QSyvjDyFTq3uoc/9xFpCuOsJQFNPiAhYdw2skhE=
github.com/klauspost/cpuid/v2 v2.2.10/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/knz/go-libedit v1.10.1/go.mod h1:MZTVkCWyz0oBc7JOWP3wNAzd002ZbM/5hgShxwh4x8M=
github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE=
github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/leodido/go-urn v1.4.0 h1:WT9HwE9SGECu3lg4d/dIA+jxlljEa1/ffXKmRjqdmIQ=
github.com/leodido/go-urn v1.4.0/go.mod h1:bvxc+MVxLKB4z00jd1z+Dvzr47oO32F/QSNjSBOlFxI=
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35 h1:PpXWgLPs+Fqr325bN2FD2ISlRRztXibcX6e8f5FR5Dc=
github.com/lufia/plan9stats v0.0.0-20250317134145-8bc96cf8fc35/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg=
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/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
github.com/mattn/go-sqlite3 v1.14.27 h1:drZCnuvf37yPfs95E5jd9s3XhdVWLal+6BOK6qrv6IU=
github.com/mattn/go-sqlite3 v1.14.27/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y=
github.com/modern-go/concurrent v0.0.0-20180228061459-e0a39a4cb421/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd h1:TRLaZ9cD/w8PVh93nsPXa1VrQ6jlwL5oN8l14QlcNfg=
github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd/go.mod h1:6dJC0mAP4ikYIbvyc7fijjWJddQyLn8Ig3JB5CqoB9Q=
github.com/modern-go/reflect2 v1.0.2 h1:xBagoLtFs94CBntxluKeaWgTMpvLxC4ur3nMaC9Gz0M=
github.com/modern-go/reflect2 v1.0.2/go.mod h1:yWuevngMOJpCy52FWWMvUC8ws7m/LJsjYzDa0/r8luk=
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU=
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE=
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922 h1:SMyqkaRfpE8ZQUSRTZKO3uN84xov++OGa+e3NCksaQw=
github.com/rivo/tview v0.0.0-20250330220935-949945f8d922/go.mod h1:02iFIz7K/A9jGCvrizLPvoqr4cEIx7q54RH5Qudkrss=
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
github.com/rivo/uniseg v0.4.3/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
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/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/sagikazarmark/locafero v0.9.0 h1:GbgQGNtTrEmddYDSAH9QLRyfAHY12md+8YFTqyMTC9k=
github.com/sagikazarmark/locafero v0.9.0/go.mod h1:UBUyz37V+EdMS3hDF3QWIiVr/2dPrx49OMO0Bn0hJqk=
github.com/shirou/gopsutil/v4 v4.25.3 h1:SeA68lsu8gLggyMbmCn8cmp97V1TI9ld9sVzAUcKcKE=
github.com/shirou/gopsutil/v4 v4.25.3/go.mod h1:xbuxyoZj+UsgnZrENu3lQivsngRR5BdjbJwf2fv4szA=
github.com/sirupsen/logrus v1.9.3 h1:dueUQJ1C2q9oE3F7wvmSGAaVtTmUizReu6fjN8uqzbQ=
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/go.mod h1:Sdozi7LEKbFPqYX2/J+iBAM6HpqSLTASQIKqDmF7Mt0=
github.com/spf13/afero v1.14.0 h1:9tH6MapGnn/j0eb0yIXiLjERO8RB6xIVZRDCX7PtqWA=
github.com/spf13/afero v1.14.0/go.mod h1:acJQ8t0ohCGuMN3O+Pv0V0hgMxNYDlvdk+VTfyZmbYo=
github.com/spf13/cast v1.7.1 h1:cuNEagBQEHWN1FnbGEjCXL2szYEXqfJPbP2HNUaca9Y=
github.com/spf13/cast v1.7.1/go.mod h1:ancEpBxwJDODSW/UG4rDrAqiKolqNNh2DX3mk86cAdo=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/spf13/viper v1.20.1 h1:ZMi+z/lvLyPSCoNtFCpqjy0S4kPbirhpTMwl8BkW9X4=
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.4.0/go.mod h1:YvHI0jy2hoMjB+UWwv71VJQ9isScKT/TqJzVSSt89Yw=
github.com/stretchr/objx v0.5.0/go.mod h1:Yh+to48EsGEfYuaHDzXPcE3xhTkx73EhmCGUpEOglKo=
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.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.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
github.com/tklauser/go-sysconf v0.3.15 h1:VE89k0criAymJ/Os65CSn1IXaol+1wrsFHEB8Ol49K4=
github.com/tklauser/go-sysconf v0.3.15/go.mod h1:Dmjwr6tYFIseJw7a3dRLJfsHAMXZ3nEnL/aZY+0IuI4=
github.com/tklauser/numcpus v0.10.0 h1:18njr6LDBk1zuna922MgdjQuJFjrdppsZG60sHGfjso=
github.com/tklauser/numcpus v0.10.0/go.mod h1:BiTKazU708GQTYF4mB+cmlpT2Is1gLk7XVuEeem8LsQ=
github.com/twitchyliquid64/golang-asm v0.15.1 h1:SU5vSMR7hnwNxj24w34ZyCi/FmDZTkS4MhqMhdFk5YI=
github.com/twitchyliquid64/golang-asm v0.15.1/go.mod h1:a1lVb/DtPvCB8fslRZhAngC2+aY1QWCk3Cedj/Gdt08=
github.com/ugorji/go/codec v1.2.12 h1:9LC83zGrHhuUA9l16C9AHXAqEV/2wBQ4nkvumAE65EE=
github.com/ugorji/go/codec v1.2.12/go.mod h1:UNopzCgEMSXjBc6AOMqYvWC1ktqTAfzJZUZgYf6w6lg=
github.com/yuin/goldmark v1.4.13/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo0=
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
golang.org/x/arch v0.16.0 h1:foMtLTdyOmIniqWCHjY6+JxuC54XP1fDwx4N0ASyW+U=
golang.org/x/arch v0.16.0/go.mod h1:JmwW7aLIoRUKgaTzhkiEFxvcEiQGyOg9BMonBJUS7EE=
golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w=
golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.13.0/go.mod h1:y6Z2r+Rw4iayiXXAIxJIDAJ1zMW4yaTpebo8fPOliYc=
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.37.0 h1:kJNSjF/Xp7kU0iB2Z+9viTPMW4EqqsrywMXLJOOsXSE=
golang.org/x/crypto v0.37.0/go.mod h1:vg+k43peMZ0pUMhYmVAWysMK35e6ioLh3wB8ZCAfbVc=
golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
golang.org/x/mod v0.8.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.12.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
golang.org/x/mod v0.15.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c=
golang.org/x/net v0.0.0-20190620200207-3b0461eec859/go.mod h1:z5CRVTTTmAJ677TzLLGU+0bjPO0LkuOLi4/5GtJWs/s=
golang.org/x/net v0.0.0-20210226172049-e18ecbb05110/go.mod h1:m0MpNAwzfU5UDzcl9v0D8zg8gWTRqZa9RBIspLL5mdg=
golang.org/x/net v0.0.0-20220722155237-a158d28d115b/go.mod h1:XRhObCWvk6IyKnWLug+ECip1KBveYUHfp+8e9klMJ9c=
golang.org/x/net v0.6.0/go.mod h1:2Tu9+aMcznHK/AK1HMvgo6xiTLG5rD5rZLDS+rp2Bjs=
golang.org/x/net v0.10.0/go.mod h1:0qNGK6F8kojg2nk9dLZ2mShWaEBan6FAoqfSigmmuDg=
golang.org/x/net v0.15.0/go.mod h1:idbUs1IY1+zTqbi8yxTbhexhEEk5ur9LInksu6HrEpk=
golang.org/x/net v0.21.0/go.mod h1:bIjVDfnllIU7BJ2DNgfnXvpSvtn8VRwhlsaeUTyUS44=
golang.org/x/net v0.25.0/go.mod h1:JkAGAh7GEvH74S6FOH42FLoXpXbE/aqXSrIQjXgsiwM=
golang.org/x/net v0.39.0 h1:ZCu7HMWDxpXpaiKdhzIfaltL9Lp31x/3fCP11bc6/fY=
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-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.3.0/go.mod h1:FU7BRWz2tNW+3quACPkgCx/L+uEAv1htQ0V83Z9Rj+Y=
golang.org/x/sync v0.6.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.7.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sync v0.10.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk=
golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210615035016-665e8c7367d1/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-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.5.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.8.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.12.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.17.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.29.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.32.0 h1:s77OFDvIQeibCmezSnk/q6iAfkdiQaJi4VzroCFrN20=
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/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.5.0/go.mod h1:jMB1sMXY+tzblOD4FWmEbocvup2/aLOaQEp7JmGp78k=
golang.org/x/term v0.8.0/go.mod h1:xPskH00ivmX89bAKVGSKKtLOWNx2+17Eiy94tnKShWo=
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.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.31.0 h1:erwDkOK1Msy6offm1mOgvspSkslFnIGsFnxOKoufg3o=
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.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.7.0/go.mod h1:mrYo+phRRbMaCq/xk9113O4dZlRixOauAjOtrjsXDZ8=
golang.org/x/text v0.9.0/go.mod h1:e1OnstbJyHTd6l/uOt8jFFHp6TRDWZR/bV3emEE/zU8=
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.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.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
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-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.6.0/go.mod h1:Xwgl3UAJ/d3gWutnCtw505GrjyAbvKui8lOU390QaIU=
golang.org/x/tools v0.13.0/go.mod h1:HvlwmtVNQAhOuCjW7xxvovg8wbNq7LwfXh/k7wXUl58=
golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk=
golang.org/x/xerrors v0.0.0-20190717185122-a985d3407aa7/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY=
google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 h1:YR8cESwS4TdDjEe65xsg0ogRM/Nc3DYOhEAlW+xobZo=
gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM=
howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g=
nullprogram.com/x/optparse v1.0.0/go.mod h1:KdyPE+Igbe0jQUrVfMqDMeJQIJZEuyV7pjYmp6pbG50=
```
## /internal/chatlog/app.go
```go path="/internal/chatlog/app.go"
package chatlog
import (
"fmt"
"path/filepath"
"runtime"
"time"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"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/infobar"
"github.com/sjzar/chatlog/internal/ui/menu"
"github.com/sjzar/chatlog/internal/wechat"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
)
const (
RefreshInterval = 1000 * time.Millisecond
)
type App struct {
*tview.Application
ctx *ctx.Context
m *Manager
stopRefresh chan struct{}
// page
mainPages *tview.Pages
infoBar *infobar.InfoBar
tabPages *tview.Pages
footer *footer.Footer
// tab
menu *menu.Menu
help *help.Help
activeTab int
tabCount int
}
func NewApp(ctx *ctx.Context, m *Manager) *App {
app := &App{
ctx: ctx,
m: m,
Application: tview.NewApplication(),
mainPages: tview.NewPages(),
infoBar: infobar.New(),
tabPages: tview.NewPages(),
footer: footer.New(),
menu: menu.New("主菜单"),
help: help.New(),
}
app.initMenu()
app.updateMenuItemsState()
return app
}
func (a *App) Run() error {
flex := tview.NewFlex().
SetDirection(tview.FlexRow).
AddItem(a.infoBar, infobar.InfoBarViewHeight, 0, false).
AddItem(a.tabPages, 0, 1, true).
AddItem(a.footer, 1, 1, false)
a.mainPages.AddPage("main", flex, true, true)
a.tabPages.
AddPage("0", a.menu, true, true).
AddPage("1", a.help, true, false)
a.tabCount = 2
a.SetInputCapture(a.inputCapture)
go a.refresh()
if err := a.SetRoot(a.mainPages, true).EnableMouse(false).Run(); err != nil {
return err
}
return nil
}
func (a *App) Stop() {
// 添加一个通道用于停止刷新 goroutine
if a.stopRefresh != nil {
close(a.stopRefresh)
}
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) {
index := (a.activeTab + step) % a.tabCount
if index < 0 {
index = a.tabCount - 1
}
a.activeTab = index
a.tabPages.SwitchToPage(fmt.Sprint(a.activeTab))
}
func (a *App) refresh() {
tick := time.NewTicker(RefreshInterval)
defer tick.Stop()
for {
select {
case <-a.stopRefresh:
return
case <-tick.C:
if a.ctx.AutoDecrypt || a.ctx.HTTPEnabled {
a.m.RefreshSession()
}
a.infoBar.UpdateAccount(a.ctx.Account)
a.infoBar.UpdateBasicInfo(a.ctx.PID, a.ctx.FullVersion, a.ctx.ExePath)
a.infoBar.UpdateStatus(a.ctx.Status)
a.infoBar.UpdateDataKey(a.ctx.DataKey)
a.infoBar.UpdatePlatform(a.ctx.Platform)
a.infoBar.UpdateDataUsageDir(a.ctx.DataUsage, a.ctx.DataDir)
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 {
a.infoBar.UpdateHTTPServer(fmt.Sprintf("[green][已启动][white] [%s]", a.ctx.HTTPAddr))
} else {
a.infoBar.UpdateHTTPServer("[未启动]")
}
if a.ctx.AutoDecrypt {
a.infoBar.UpdateAutoDecrypt("[green][已开启][white]")
} else {
a.infoBar.UpdateAutoDecrypt("[未开启]")
}
a.Draw()
}
}
}
func (a *App) inputCapture(event *tcell.EventKey) *tcell.EventKey {
// 如果当前页面不是主页面,ESC 键返回主页面
if a.mainPages.HasPage("submenu") && event.Key() == tcell.KeyEscape {
a.mainPages.RemovePage("submenu")
a.mainPages.SwitchToPage("main")
return nil
}
if a.tabPages.HasFocus() {
switch event.Key() {
case tcell.KeyLeft:
a.switchTab(-1)
return nil
case tcell.KeyRight:
a.switchTab(1)
return nil
}
}
switch event.Key() {
case tcell.KeyCtrlC:
a.Stop()
}
return event
}
func (a *App) initMenu() {
getDataKey := &menu.Item{
Index: 2,
Name: "获取数据密钥",
Description: "从进程获取数据密钥",
Selected: func(i *menu.Item) {
modal := tview.NewModal()
if runtime.GOOS == "darwin" {
modal.SetText("获取数据密钥中...\n预计需要 20 秒左右的时间,期间微信会卡住,请耐心等待")
} else {
modal.SetText("获取数据密钥中...")
}
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)
})
}()
},
}
decryptData := &menu.Item{
Index: 3,
Name: "解密数据",
Description: "解密数据文件",
Selected: func(i *menu.Item) {
// 创建一个没有按钮的模态框,显示"解密中..."
modal := tview.NewModal().
SetText("解密中...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台执行解密操作
go func() {
// 执行解密
err := a.m.DecryptDBFiles()
// 在主线程中更新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)
})
}()
},
}
httpServer := &menu.Item{
Index: 4,
Name: "启动 HTTP 服务",
Description: "启动本地 HTTP & MCP 服务器",
Selected: func(i *menu.Item) {
modal := tview.NewModal()
// 根据当前服务状态执行不同操作
if !a.ctx.HTTPEnabled {
// HTTP 服务未启动,启动服务
modal.SetText("正在启动 HTTP 服务...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台启动服务
go func() {
err := a.m.StartService()
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
if err != nil {
// 启动失败
modal.SetText("启动 HTTP 服务失败: " + err.Error())
} else {
// 启动成功
modal.SetText("已启动 HTTP 服务")
}
// 更改菜单项名称
a.updateMenuItemsState()
// 添加确认按钮
modal.AddButtons([]string{"OK"})
modal.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
a.SetFocus(modal)
})
}()
} else {
// HTTP 服务已启动,停止服务
modal.SetText("正在停止 HTTP 服务...")
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
// 在后台停止服务
go func() {
err := a.m.StopService()
// 在主线程中更新UI
a.QueueUpdateDraw(func() {
if err != nil {
// 停止失败
modal.SetText("停止 HTTP 服务失败: " + err.Error())
} else {
// 停止成功
modal.SetText("已停止 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.SetDoneFunc(func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
a.SetFocus(modal)
})
}()
}
},
}
setting := &menu.Item{
Index: 6,
Name: "设置",
Description: "设置应用程序选项",
Selected: a.settingSelected,
}
selectAccount := &menu.Item{
Index: 7,
Name: "切换账号",
Description: "切换当前操作的账号,可以选择进程或历史账号",
Selected: a.selectAccountSelected,
}
a.menu.AddItem(getDataKey)
a.menu.AddItem(decryptData)
a.menu.AddItem(httpServer)
a.menu.AddItem(autoDecrypt)
a.menu.AddItem(setting)
a.menu.AddItem(selectAccount)
a.menu.AddItem(&menu.Item{
Index: 8,
Name: "退出",
Description: "退出程序",
Selected: func(i *menu.Item) {
a.Stop()
},
})
}
// settingItem 表示一个设置项
type settingItem struct {
name string
description string
action func()
}
func (a *App) settingSelected(i *menu.Item) {
settings := []settingItem{
{
name: "设置 HTTP 服务地址",
description: "配置 HTTP 服务监听的地址",
action: a.settingHTTPPort,
},
{
name: "设置工作目录",
description: "配置数据解密后的存储目录",
action: a.settingWorkDir,
},
{
name: "设置数据密钥",
description: "配置数据解密密钥",
action: a.settingDataKey,
},
{
name: "设置数据目录",
description: "配置微信数据文件所在目录",
action: a.settingDataDir,
},
}
subMenu := menu.NewSubMenu("设置")
for idx, setting := range settings {
item := &menu.Item{
Index: idx + 1,
Name: setting.name,
Description: setting.description,
Selected: func(action func()) func(*menu.Item) {
return func(*menu.Item) {
action()
}
}(setting.action),
}
subMenu.AddItem(item)
}
a.mainPages.AddPage("submenu", subMenu, true, true)
a.SetFocus(subMenu)
}
// settingHTTPPort 设置 HTTP 端口
func (a *App) settingHTTPPort() {
// 使用我们的自定义表单组件
formView := form.NewForm("设置 HTTP 地址")
// 临时存储用户输入的值
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 设置工作目录
func (a *App) settingWorkDir() {
// 使用我们的自定义表单组件
formView := form.NewForm("设置工作目录")
// 临时存储用户输入的值
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 显示一个模态对话框
func (a *App) showModal(text string, buttons []string, doneFunc func(buttonIndex int, buttonLabel string)) {
modal := tview.NewModal().
SetText(text).
AddButtons(buttons).
SetDoneFunc(doneFunc)
a.mainPages.AddPage("modal", modal, true, true)
a.SetFocus(modal)
}
// showError 显示错误对话框
func (a *App) showError(err error) {
a.showModal(err.Error(), []string{"OK"}, func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
}
// showInfo 显示信息对话框
func (a *App) showInfo(text string) {
a.showModal(text, []string{"OK"}, func(buttonIndex int, buttonLabel string) {
a.mainPages.RemovePage("modal")
})
}
```
## /internal/chatlog/conf/config.go
```go path="/internal/chatlog/conf/config.go"
package conf
import "github.com/sjzar/chatlog/pkg/config"
type Config struct {
ConfigDir string `mapstructure:"-"`
LastAccount string `mapstructure:"last_account" json:"last_account"`
History []ProcessConfig `mapstructure:"history" json:"history"`
}
type ProcessConfig struct {
Type string `mapstructure:"type" json:"type"`
Account string `mapstructure:"account" json:"account"`
Platform string `mapstructure:"platform" json:"platform"`
Version int `mapstructure:"version" json:"version"`
FullVersion string `mapstructure:"full_version" json:"full_version"`
DataDir string `mapstructure:"data_dir" json:"data_dir"`
DataKey string `mapstructure:"data_key" json:"data_key"`
WorkDir string `mapstructure:"work_dir" json:"work_dir"`
HTTPEnabled bool `mapstructure:"http_enabled" json:"http_enabled"`
HTTPAddr string `mapstructure:"http_addr" json:"http_addr"`
LastTime int64 `mapstructure:"last_time" json:"last_time"`
Files []File `mapstructure:"files" json:"files"`
}
type File struct {
Path string `mapstructure:"path" json:"path"`
ModifiedTime int64 `mapstructure:"modified_time" json:"modified_time"`
Size int64 `mapstructure:"size" json:"size"`
}
func (c *Config) ParseHistory() map[string]ProcessConfig {
m := make(map[string]ProcessConfig)
for _, v := range c.History {
m[v.Account] = v
}
return m
}
func (c *Config) UpdateHistory(account string, conf ProcessConfig) error {
if c.History == nil {
c.History = make([]ProcessConfig, 0)
}
if len(c.History) == 0 {
c.History = append(c.History, conf)
} else {
isFind := false
for i, v := range c.History {
if v.Account == account {
isFind = true
c.History[i] = conf
break
}
}
if !isFind {
c.History = append(c.History, conf)
}
}
config.SetConfig("last_account", account)
return config.SetConfig("history", c.History)
}
```
## /internal/chatlog/conf/service.go
```go path="/internal/chatlog/conf/service.go"
package conf
import (
"log"
"os"
"sync"
"github.com/sjzar/chatlog/pkg/config"
)
const (
ConfigName = "chatlog"
ConfigType = "json"
EnvConfigDir = "CHATLOG_DIR"
)
// Service 配置服务
type Service struct {
configPath string
config *Config
mu sync.RWMutex
}
// NewService 创建配置服务
func NewService(configPath string) (*Service, error) {
service := &Service{
configPath: configPath,
}
if err := service.Load(); err != nil {
return nil, err
}
return service, nil
}
// Load 加载配置
func (s *Service) Load() error {
s.mu.Lock()
defer s.mu.Unlock()
configPath := s.configPath
if configPath == "" {
configPath = os.Getenv(EnvConfigDir)
}
if err := config.Init(ConfigName, ConfigType, configPath); err != nil {
log.Fatal(err)
}
conf := &Config{}
if err := config.Load(conf); err != nil {
log.Fatal(err)
}
conf.ConfigDir = config.ConfigPath
s.config = conf
return nil
}
// GetConfig 获取配置副本
func (s *Service) GetConfig() *Config {
s.mu.RLock()
defer s.mu.RUnlock()
// 返回配置副本
configCopy := *s.config
return &configCopy
}
```
## /internal/chatlog/ctx/context.go
```go path="/internal/chatlog/ctx/context.go"
package ctx
import (
"sync"
"time"
"github.com/sjzar/chatlog/internal/chatlog/conf"
"github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/pkg/util"
)
// Context is a context for a chatlog.
// It is used to store information about the chatlog.
type Context struct {
conf *conf.Service
mu sync.RWMutex
History map[string]conf.ProcessConfig
// 微信账号相关状态
Account string
Platform string
Version int
FullVersion string
DataDir string
DataKey string
DataUsage string
// 工作目录相关状态
WorkDir string
WorkUsage string
// HTTP服务相关状态
HTTPEnabled bool
HTTPAddr string
// 自动解密
AutoDecrypt bool
LastSession time.Time
// 当前选中的微信实例
Current *wechat.Account
PID int
ExePath string
Status string
// 所有可用的微信实例
WeChatInstances []*wechat.Account
}
func New(conf *conf.Service) *Context {
ctx := &Context{
conf: conf,
}
ctx.loadConfig()
return ctx
}
func (c *Context) loadConfig() {
conf := c.conf.GetConfig()
c.History = conf.ParseHistory()
c.SwitchHistory(conf.LastAccount)
c.Refresh()
}
func (c *Context) SwitchHistory(account string) {
c.mu.Lock()
defer c.mu.Unlock()
c.Current = nil
c.PID = 0
c.ExePath = ""
c.Status = ""
history, ok := c.History[account]
if ok {
c.Account = history.Account
c.Platform = history.Platform
c.Version = history.Version
c.FullVersion = history.FullVersion
c.DataKey = history.DataKey
c.DataDir = history.DataDir
c.WorkDir = history.WorkDir
c.HTTPEnabled = history.HTTPEnabled
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.Account) {
c.SwitchHistory(info.Name)
c.mu.Lock()
defer c.mu.Unlock()
c.Current = info
c.Refresh()
}
func (c *Context) Refresh() {
if c.Current != nil {
c.Account = c.Current.Name
c.Platform = c.Current.Platform
c.Version = c.Current.Version
c.FullVersion = c.Current.FullVersion
c.PID = int(c.Current.PID)
c.ExePath = c.Current.ExePath
c.Status = c.Current.Status
if c.Current.Key != "" && c.Current.Key != c.DataKey {
c.DataKey = c.Current.Key
}
if c.Current.DataDir != "" && c.Current.DataDir != c.DataDir {
c.DataDir = c.Current.DataDir
}
}
if c.DataUsage == "" && c.DataDir != "" {
go func() {
c.DataUsage = util.GetDirSize(c.DataDir)
}()
}
if c.WorkUsage == "" && c.WorkDir != "" {
go func() {
c.WorkUsage = util.GetDirSize(c.WorkDir)
}()
}
}
func (c *Context) SetHTTPEnabled(enabled bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.HTTPEnabled = enabled
c.UpdateConfig()
}
func (c *Context) SetHTTPAddr(addr string) {
c.mu.Lock()
defer c.mu.Unlock()
c.HTTPAddr = addr
c.UpdateConfig()
}
func (c *Context) SetWorkDir(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
c.WorkDir = dir
c.UpdateConfig()
c.Refresh()
}
func (c *Context) SetDataDir(dir string) {
c.mu.Lock()
defer c.mu.Unlock()
c.DataDir = dir
c.UpdateConfig()
c.Refresh()
}
func (c *Context) SetAutoDecrypt(enabled bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.AutoDecrypt = enabled
c.UpdateConfig()
}
// 更新配置
func (c *Context) UpdateConfig() {
pconf := conf.ProcessConfig{
Type: "wechat",
Account: c.Account,
Platform: c.Platform,
Version: c.Version,
FullVersion: c.FullVersion,
DataDir: c.DataDir,
DataKey: c.DataKey,
WorkDir: c.WorkDir,
HTTPEnabled: c.HTTPEnabled,
HTTPAddr: c.HTTPAddr,
}
conf := c.conf.GetConfig()
conf.UpdateHistory(c.Account, pconf)
}
```
## /internal/chatlog/database/service.go
```go path="/internal/chatlog/database/service.go"
package database
import (
"time"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/model"
"github.com/sjzar/chatlog/internal/wechatdb"
)
type Service struct {
ctx *ctx.Context
db *wechatdb.DB
}
func NewService(ctx *ctx.Context) *Service {
return &Service{
ctx: ctx,
}
}
func (s *Service) Start() error {
db, err := wechatdb.New(s.ctx.WorkDir, s.ctx.Platform, s.ctx.Version)
if err != nil {
return err
}
s.db = db
return nil
}
func (s *Service) Stop() error {
if s.db != nil {
s.db.Close()
}
s.db = nil
return nil
}
func (s *Service) GetDB() *wechatdb.DB {
return s.db
}
func (s *Service) GetMessages(start, end time.Time, talker string, sender string, keyword string, limit, offset int) ([]*model.Message, error) {
return s.db.GetMessages(start, end, talker, sender, keyword, limit, offset)
}
func (s *Service) GetContacts(key string, limit, offset int) (*wechatdb.GetContactsResp, error) {
return s.db.GetContacts(key, limit, offset)
}
func (s *Service) GetChatRooms(key string, limit, offset int) (*wechatdb.GetChatRoomsResp, error) {
return s.db.GetChatRooms(key, limit, offset)
}
// GetSession retrieves session information
func (s *Service) GetSessions(key string, limit, offset int) (*wechatdb.GetSessionsResp, error) {
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
func (s *Service) Close() {
// Add cleanup code if needed
s.db.Close()
}
```
## /internal/chatlog/http/route.go
```go path="/internal/chatlog/http/route.go"
package http
import (
"embed"
"fmt"
"io/fs"
"net/http"
"os"
"path/filepath"
"strings"
"github.com/sjzar/chatlog/internal/errors"
"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"
)
// EFS holds embedded file system data for static assets.
//
//go:embed static
var EFS embed.FS
func (s *Service) initRouter() {
router := s.GetRouter()
staticDir, _ := fs.Sub(EFS, "static")
router.StaticFS("/static", http.FS(staticDir))
router.StaticFileFS("/favicon.ico", "./favicon.ico", 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
{
router.GET("/sse", s.mcp.HandleSSE)
router.POST("/messages", s.mcp.HandleMessages)
// mcp inspector is shit
// https://github.com/modelcontextprotocol/inspector/blob/aeaf32f/server/src/index.ts#L155
router.POST("/message", s.mcp.HandleMessages)
}
// API V1 Router
api := router.Group("/api/v1")
{
api.GET("/chatlog", s.GetChatlog)
api.GET("/contact", s.GetContacts)
api.GET("/chatroom", s.GetChatRooms)
api.GET("/session", s.GetSessions)
}
router.NoRoute(s.NoRoute)
}
// NoRoute handles 404 Not Found errors. If the request URL starts with "/api"
// or "/static", it responds with a JSON error. Otherwise, it redirects to the root path.
func (s *Service) NoRoute(c *gin.Context) {
path := c.Request.URL.Path
switch {
case strings.HasPrefix(path, "/api"), strings.HasPrefix(path, "/static"):
c.JSON(http.StatusNotFound, gin.H{"error": "Not found"})
default:
c.Header("Cache-Control", "no-cache, no-store, max-age=0, must-revalidate, value")
c.Redirect(http.StatusFound, "/")
}
}
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
start, end, ok := util.TimeRangeOf(q.Time)
if !ok {
errors.Err(c, errors.InvalidArg("time"))
}
if q.Limit < 0 {
q.Limit = 0
}
if q.Offset < 0 {
q.Offset = 0
}
messages, err := s.db.GetMessages(start, end, q.Talker, q.Sender, q.Keyword, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
switch strings.ToLower(q.Format) {
case "csv":
case "json":
// json
c.JSON(http.StatusOK, messages)
default:
// plain text
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
for _, m := range messages {
c.Writer.WriteString(m.PlainText(strings.Contains(q.Talker, ","), util.PerfectTimeFormat(start, end), c.Request.Host))
c.Writer.WriteString("\n")
c.Writer.Flush()
}
}
}
func (s *Service) GetContacts(c *gin.Context) {
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 {
errors.Err(c, err)
return
}
format := strings.ToLower(q.Format)
switch format {
case "json":
// json
c.JSON(http.StatusOK, list)
default:
// csv
if format == "csv" {
// 浏览器访问时,会下载文件
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
} else {
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
c.Writer.WriteString("UserName,Alias,Remark,NickName\n")
for _, contact := range list.Items {
c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
}
c.Writer.Flush()
}
}
func (s *Service) GetChatRooms(c *gin.Context) {
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.GetChatRooms(q.Keyword, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
format := strings.ToLower(q.Format)
switch format {
case "json":
// json
c.JSON(http.StatusOK, list)
default:
// csv
if format == "csv" {
// 浏览器访问时,会下载文件
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
} else {
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
}
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
c.Writer.WriteString("Name,Remark,NickName,Owner,UserCount\n")
for _, chatRoom := range list.Items {
c.Writer.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
}
c.Writer.Flush()
}
}
func (s *Service) GetSessions(c *gin.Context) {
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
}
sessions, err := s.db.GetSessions(q.Keyword, q.Limit, q.Offset)
if err != nil {
errors.Err(c, err)
return
}
format := strings.ToLower(q.Format)
switch format {
case "csv":
c.Writer.Header().Set("Content-Type", "text/csv; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
c.Writer.WriteString("UserName,NOrder,NickName,Content,NTime\n")
for _, session := range sessions.Items {
c.Writer.WriteString(fmt.Sprintf("%s,%d,%s,%s,%s\n", session.UserName, session.NOrder, session.NickName, strings.ReplaceAll(session.Content, "\n", "\\n"), session.NTime))
}
c.Writer.Flush()
case "json":
// json
c.JSON(http.StatusOK, sessions)
default:
c.Writer.Header().Set("Content-Type", "text/plain; charset=utf-8")
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Flush()
for _, session := range sessions.Items {
c.Writer.WriteString(session.PlainText(120))
c.Writer.WriteString("\n")
}
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)
}
```
## /internal/chatlog/http/service.go
```go path="/internal/chatlog/http/service.go"
package http
import (
"context"
"net/http"
"time"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/chatlog/mcp"
"github.com/sjzar/chatlog/internal/errors"
"github.com/gin-gonic/gin"
"github.com/rs/zerolog/log"
)
const (
DefalutHTTPAddr = "127.0.0.1:5030"
)
type Service struct {
ctx *ctx.Context
db *database.Service
mcp *mcp.Service
router *gin.Engine
server *http.Server
}
func NewService(ctx *ctx.Context, db *database.Service, mcp *mcp.Service) *Service {
gin.SetMode(gin.ReleaseMode)
router := gin.New()
// Handle error from SetTrustedProxies
if err := router.SetTrustedProxies(nil); err != nil {
log.Err(err).Msg("Failed to set trusted proxies")
}
// Middleware
router.Use(
errors.RecoveryMiddleware(),
errors.ErrorHandlerMiddleware(),
gin.LoggerWithWriter(log.Logger),
)
s := &Service{
ctx: ctx,
db: db,
mcp: mcp,
router: router,
}
s.initRouter()
return s
}
func (s *Service) Start() error {
if s.ctx.HTTPAddr == "" {
s.ctx.HTTPAddr = DefalutHTTPAddr
}
s.server = &http.Server{
Addr: s.ctx.HTTPAddr,
Handler: s.router,
}
go func() {
// Handle error from Run
if err := s.server.ListenAndServe(); err != nil {
log.Err(err).Msg("Failed to start HTTP server")
}
}()
log.Info().Msg("Starting HTTP server on " + s.ctx.HTTPAddr)
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 {
if s.server == nil {
return nil
}
// 使用超时上下文优雅关闭
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if err := s.server.Shutdown(ctx); err != nil {
log.Debug().Err(err).Msg("Failed to shutdown HTTP server")
return nil
}
log.Info().Msg("HTTP server stopped")
return nil
}
func (s *Service) GetRouter() *gin.Engine {
return s.router
}
```
## /internal/chatlog/http/static/index.htm
```htm path="/internal/chatlog/http/static/index.htm"
Chatlog
🎉 恭喜!Chatlog 服务已成功启动
Chatlog 是一个帮助你轻松使用自己聊天数据的工具,现在你可以通过 HTTP
API 访问你的聊天记录、联系人和群聊信息。
🔍 API 接口与调试
查询最近会话列表。GET /api/v1/session
输出格式:可选
默认
JSON
纯文本
查询群聊列表,可选择性地按关键词搜索。GET /api/v1/chatroom
搜索群聊:可选
输出格式:可选
默认
JSON
纯文本
查询联系人列表,可选择性地按关键词搜索。GET /api/v1/contact
搜索联系人:可选
输出格式:可选
默认
JSON
纯文本
执行查询
🤖 MCP 集成
Chatlog 支持 MCP (Model Context Protocol) SSE 协议,可与支持 MCP 的 AI
助手无缝集成。
SSE 端点:/sse
详细集成指南请参考
MCP 集成指南
```
## /internal/chatlog/manager.go
```go path="/internal/chatlog/manager.go"
package chatlog
import (
"context"
"fmt"
"path/filepath"
"strings"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/chatlog/conf"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/chatlog/http"
"github.com/sjzar/chatlog/internal/chatlog/mcp"
"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/dat2img"
)
// Manager 管理聊天日志应用
type Manager struct {
conf *conf.Service
ctx *ctx.Context
// Services
db *database.Service
http *http.Service
mcp *mcp.Service
wechat *wechat.Service
// Terminal UI
app *App
}
func New(configPath string) (*Manager, error) {
// 创建配置服务
conf, err := conf.NewService(configPath)
if err != nil {
return nil, err
}
// 创建应用上下文
ctx := ctx.New(conf)
wechat := wechat.NewService(ctx)
db := database.NewService(ctx)
mcp := mcp.NewService(ctx, db)
http := http.NewService(ctx, db, mcp)
return &Manager{
conf: conf,
ctx: ctx,
db: db,
mcp: mcp,
http: http,
wechat: wechat,
}, nil
}
func (m *Manager) Run() error {
m.ctx.WeChatInstances = m.wechat.GetWeChatInstances()
if len(m.ctx.WeChatInstances) >= 1 {
m.ctx.SwitchCurrent(m.ctx.WeChatInstances[0])
}
if m.ctx.HTTPEnabled {
// 启动HTTP服务
if err := m.StartService(); err != nil {
m.StopService()
}
}
// 启动终端UI
m.app = NewApp(m.ctx, m)
m.app.Run() // 阻塞
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 {
// 按依赖顺序启动服务
if err := m.db.Start(); err != nil {
return err
}
if err := m.mcp.Start(); err != nil {
m.db.Stop() // 回滚已启动的服务
return err
}
if err := m.http.Start(); err != nil {
m.mcp.Stop() // 回滚已启动的服务
m.db.Stop()
return err
}
// 如果是 4.0 版本,更新下 xorkey
if m.ctx.Version == 4 {
go dat2img.ScanAndSetXorKey(m.ctx.DataDir)
}
// 更新状态
m.ctx.SetHTTPEnabled(true)
return nil
}
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
if err := m.http.Stop(); err != nil {
errs = append(errs, err)
}
if err := m.mcp.Stop(); err != nil {
errs = append(errs, err)
}
if err := m.db.Stop(); err != nil {
errs = append(errs, err)
}
// 如果有错误,返回第一个错误
if len(errs) > 0 {
return errs[0]
}
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 {
if m.ctx.Current == nil {
return fmt.Errorf("未选择任何账号")
}
if _, err := m.wechat.GetDataKey(m.ctx.Current); err != nil {
return err
}
m.ctx.Refresh()
m.ctx.UpdateConfig()
return nil
}
func (m *Manager) DecryptDBFiles() error {
if m.ctx.DataKey == "" {
if m.ctx.Current == nil {
return fmt.Errorf("未选择任何账号")
}
if err := m.GetDataKey(); err != nil {
return err
}
}
if m.ctx.WorkDir == "" {
m.ctx.WorkDir = util.DefaultWorkDir(m.ctx.Account)
}
if err := m.wechat.DecryptDBFiles(); err != nil {
return err
}
m.ctx.Refresh()
m.ctx.UpdateConfig()
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) {
instances := m.wechat.GetWeChatInstances()
if len(instances) == 0 {
return "", fmt.Errorf("wechat process not found")
}
if len(instances) == 1 {
return instances[0].GetKey(context.Background())
}
if pid == 0 {
str := "Select a process:\n"
for _, ins := range instances {
str += fmt.Sprintf("PID: %d. %s[Version: %s Data Dir: %s ]\n", ins.PID, ins.Name, ins.FullVersion, ins.DataDir)
}
return str, nil
}
for _, ins := range instances {
if ins.PID == uint32(pid) {
return ins.GetKey(context.Background())
}
}
return "", fmt.Errorf("wechat process not found")
}
func (m *Manager) CommandDecrypt(dataDir string, workDir string, key string, platform string, version int) error {
if dataDir == "" {
return fmt.Errorf("dataDir is required")
}
if key == "" {
return fmt.Errorf("key is required")
}
if workDir == "" {
workDir = util.DefaultWorkDir(filepath.Base(filepath.Dir(dataDir)))
}
m.ctx.DataDir = dataDir
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 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()
}
```
## /internal/chatlog/mcp/const.go
```go path="/internal/chatlog/mcp/const.go"
package mcp
import (
"github.com/sjzar/chatlog/internal/mcp"
)
// MCPTools 和资源定义
var (
InitializeResponse = mcp.InitializeResponse{
ProtocolVersion: mcp.ProtocolVersion,
Capabilities: mcp.DefaultCapabilities,
ServerInfo: mcp.ServerInfo{
Name: "chatlog",
Version: "0.0.1",
},
}
ToolContact = mcp.Tool{
Name: "query_contact",
Description: "查询用户的联系人信息。可以通过姓名、备注名或ID进行查询,返回匹配的联系人列表。当用户询问某人的联系方式、想了解联系人信息或需要查找特定联系人时使用此工具。参数为空时,将返回联系人列表",
InputSchema: mcp.ToolSchema{
Type: "object",
Properties: mcp.M{
"keyword": mcp.M{
"type": "string",
"description": "联系人的搜索关键词,可以是姓名、备注名或ID。",
},
},
Required: []string{"keyword"},
},
}
ToolChatRoom = mcp.Tool{
Name: "query_chat_room",
Description: "查询用户参与的群聊信息。可以通过群名称、群ID或相关关键词进行查询,返回匹配的群聊列表。当用户询问群聊信息、想了解某个群的详情或需要查找特定群聊时使用此工具。",
InputSchema: mcp.ToolSchema{
Type: "object",
Properties: mcp.M{
"keyword": mcp.M{
"type": "string",
"description": "群聊的搜索关键词,可以是群名称、群ID或相关描述",
},
},
Required: []string{"keyword"},
},
}
ToolRecentChat = mcp.Tool{
Name: "query_recent_chat",
Description: "查询最近会话列表,包括个人聊天和群聊。当用户想了解最近的聊天记录、查看最近联系过的人或群组时使用此工具。不需要参数,直接返回最近的会话列表。",
InputSchema: mcp.ToolSchema{
Type: "object",
Properties: mcp.M{},
},
}
ToolChatLog = mcp.Tool{
Name: "chatlog",
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{
Type: "object",
Properties: mcp.M{
"time": mcp.M{
"type": "string",
"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{
"type": "string",
"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{},
},
}
ResourceRecentChat = mcp.Resource{
Name: "最近会话",
URI: "session://recent",
Description: "获取最近的聊天会话列表",
}
ResourceTemplateContact = mcp.ResourceTemplate{
Name: "联系人信息",
URITemplate: "contact://{username}",
Description: "获取指定联系人的详细信息",
}
ResourceTemplateChatRoom = mcp.ResourceTemplate{
Name: "群聊信息",
URITemplate: "chatroom://{roomid}",
Description: "获取指定群聊的详细信息",
}
ResourceTemplateChatlog = mcp.ResourceTemplate{
Name: "聊天记录",
URITemplate: "chatlog://{talker}/{timeframe}?limit,offset",
Description: "获取与特定联系人或群聊的聊天记录",
}
)
```
## /internal/chatlog/mcp/service.go
```go path="/internal/chatlog/mcp/service.go"
package mcp
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"net/url"
"strings"
"time"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/chatlog/database"
"github.com/sjzar/chatlog/internal/mcp"
"github.com/sjzar/chatlog/pkg/util"
"github.com/gin-gonic/gin"
)
type Service struct {
ctx *ctx.Context
db *database.Service
mcp *mcp.MCP
}
func NewService(ctx *ctx.Context, db *database.Service) *Service {
return &Service{
ctx: ctx,
db: db,
}
}
// GetMCP 获取底层MCP实例
func (s *Service) GetMCP() *mcp.MCP {
return s.mcp
}
// Start 启动MCP服务
func (s *Service) Start() error {
s.mcp = mcp.NewMCP()
go s.worker()
return nil
}
// Stop 停止MCP服务
func (s *Service) Stop() error {
if s.mcp != nil {
s.mcp.Close()
}
return nil
}
// worker 处理MCP请求
func (s *Service) worker() {
for {
select {
case p, ok := <-s.mcp.ProcessChan:
if !ok {
return
}
s.processMCP(p.Session, p.Request)
}
}
}
func (s *Service) HandleSSE(c *gin.Context) {
s.mcp.HandleSSE(c)
}
func (s *Service) HandleMessages(c *gin.Context) {
s.mcp.HandleMessages(c)
}
// processMCP 处理MCP请求
func (s *Service) processMCP(session *mcp.Session, req *mcp.Request) {
var err error
switch req.Method {
case mcp.MethodInitialize:
err = s.initialize(session, req)
case mcp.MethodToolsList:
err = s.sendCustomParams(session, req, mcp.M{"tools": []mcp.Tool{
ToolContact,
ToolChatRoom,
ToolRecentChat,
ToolChatLog,
ToolCurrentTime,
}})
case mcp.MethodToolsCall:
err = s.toolsCall(session, req)
case mcp.MethodPromptsList:
err = s.sendCustomParams(session, req, mcp.M{"prompts": []mcp.Prompt{}})
case mcp.MethodResourcesList:
err = s.sendCustomParams(session, req, mcp.M{"resources": []mcp.Resource{
ResourceRecentChat,
}})
case mcp.MethodResourcesTemplateList:
err = s.sendCustomParams(session, req, mcp.M{"resourceTemplates": []mcp.ResourceTemplate{
ResourceTemplateContact,
ResourceTemplateChatRoom,
ResourceTemplateChatlog,
}})
case mcp.MethodResourcesRead:
err = s.resourcesRead(session, req)
case mcp.MethodPing:
err = s.sendCustomParams(session, req, struct{}{})
}
if err != nil {
session.WriteError(req, err)
}
}
// initialize 处理初始化请求
func (s *Service) initialize(session *mcp.Session, req *mcp.Request) error {
initReq, err := parseParams[mcp.InitializeRequest](req.Params)
if err != nil {
return fmt.Errorf("解析初始化参数失败: %v", err)
}
session.SaveClientInfo(initReq.ClientInfo)
return session.WriteResponse(req, InitializeResponse)
}
// toolsCall 处理工具调用
func (s *Service) toolsCall(session *mcp.Session, req *mcp.Request) error {
callReq, err := parseParams[mcp.ToolsCallRequest](req.Params)
if err != nil {
return fmt.Errorf("解析工具调用参数失败: %v", err)
}
buf := &bytes.Buffer{}
switch callReq.Name {
case "query_contact":
keyword := ""
if v, ok := callReq.Arguments["keyword"]; ok {
keyword = v.(string)
}
limit := util.MustAnyToInt(callReq.Arguments["limit"])
offset := util.MustAnyToInt(callReq.Arguments["offset"])
list, err := s.db.GetContacts(keyword, limit, offset)
if err != nil {
return fmt.Errorf("无法获取联系人列表: %v", err)
}
buf.WriteString("UserName,Alias,Remark,NickName\n")
for _, contact := range list.Items {
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
}
case "query_chat_room":
keyword := ""
if v, ok := callReq.Arguments["keyword"]; ok {
keyword = v.(string)
}
limit := util.MustAnyToInt(callReq.Arguments["limit"])
offset := util.MustAnyToInt(callReq.Arguments["offset"])
list, err := s.db.GetChatRooms(keyword, limit, offset)
if err != nil {
return fmt.Errorf("无法获取群聊列表: %v", err)
}
buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
for _, chatRoom := range list.Items {
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
}
case "query_recent_chat":
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 {
return fmt.Errorf("无法获取会话列表: %v", err)
}
for _, session := range data.Items {
buf.WriteString(session.PlainText(120))
buf.WriteString("\n")
}
case "chatlog":
if callReq.Arguments == nil {
return mcp.ErrInvalidParams
}
_time := ""
if v, ok := callReq.Arguments["time"]; ok {
_time = v.(string)
}
start, end, ok := util.TimeRangeOf(_time)
if !ok {
return fmt.Errorf("无法解析时间范围")
}
talker := ""
if v, ok := callReq.Arguments["talker"]; ok {
talker = v.(string)
}
sender := ""
if v, ok := callReq.Arguments["sender"]; ok {
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"])
messages, err := s.db.GetMessages(start, end, talker, sender, keyword, limit, offset)
if err != nil {
return fmt.Errorf("无法获取聊天记录: %v", err)
}
if len(messages) == 0 {
buf.WriteString("未找到符合查询条件的聊天记录")
}
for _, m := range messages {
buf.WriteString(m.PlainText(strings.Contains(talker, ","), util.PerfectTimeFormat(start, end), ""))
buf.WriteString("\n")
}
case "current_time":
buf.WriteString(time.Now().Local().Format(time.RFC3339))
default:
return fmt.Errorf("未支持的工具: %s", callReq.Name)
}
resp := mcp.ToolsCallResponse{
Content: []mcp.Content{
{Type: "text", Text: buf.String()},
},
IsError: false,
}
return session.WriteResponse(req, resp)
}
// resourcesRead 处理资源读取
func (s *Service) resourcesRead(session *mcp.Session, req *mcp.Request) error {
readReq, err := parseParams[mcp.ResourcesReadRequest](req.Params)
if err != nil {
return fmt.Errorf("解析资源读取参数失败: %v", err)
}
u, err := url.Parse(readReq.URI)
if err != nil {
return fmt.Errorf("无法解析URI: %v", err)
}
buf := &bytes.Buffer{}
switch u.Scheme {
case "contact":
list, err := s.db.GetContacts(u.Host, 0, 0)
if err != nil {
return fmt.Errorf("无法获取联系人列表: %v", err)
}
buf.WriteString("UserName,Alias,Remark,NickName\n")
for _, contact := range list.Items {
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s\n", contact.UserName, contact.Alias, contact.Remark, contact.NickName))
}
case "chatroom":
list, err := s.db.GetChatRooms(u.Host, 0, 0)
if err != nil {
return fmt.Errorf("无法获取群聊列表: %v", err)
}
buf.WriteString("Name,Remark,NickName,Owner,UserCount\n")
for _, chatRoom := range list.Items {
buf.WriteString(fmt.Sprintf("%s,%s,%s,%s,%d\n", chatRoom.Name, chatRoom.Remark, chatRoom.NickName, chatRoom.Owner, len(chatRoom.Users)))
}
case "session":
data, err := s.db.GetSessions("", 0, 0)
if err != nil {
return fmt.Errorf("无法获取会话列表: %v", err)
}
for _, session := range data.Items {
buf.WriteString(session.PlainText(120))
buf.WriteString("\n")
}
case "chatlog":
start, end, ok := util.TimeRangeOf(strings.TrimPrefix(u.Path, "/"))
if !ok {
return fmt.Errorf("无法解析时间范围")
}
limit := util.MustAnyToInt(u.Query().Get("limit"))
offset := util.MustAnyToInt(u.Query().Get("offset"))
messages, err := s.db.GetMessages(start, end, u.Host, "", "", limit, offset)
if err != nil {
return fmt.Errorf("无法获取聊天记录: %v", err)
}
if len(messages) == 0 {
buf.WriteString("未找到符合查询条件的聊天记录")
}
for _, m := range messages {
buf.WriteString(m.PlainText(strings.Contains(u.Host, ","), util.PerfectTimeFormat(start, end), ""))
buf.WriteString("\n")
}
default:
return fmt.Errorf("不支持的URI: %s", readReq.URI)
}
resp := mcp.ReadingResource{
Contents: []mcp.ReadingResourceContent{
{URI: readReq.URI, Text: buf.String()},
},
}
return session.WriteResponse(req, resp)
}
// sendCustomParams 发送自定义参数
func (s *Service) sendCustomParams(session *mcp.Session, req *mcp.Request, params interface{}) error {
b, err := json.Marshal(mcp.NewResponse(req.ID, params))
if err != nil {
return fmt.Errorf("无法序列化响应: %v", err)
}
session.Write(b)
return nil
}
// parseParams 解析参数
func parseParams[T any](params interface{}) (*T, error) {
if params == nil {
return nil, errors.New("params is nil")
}
// 将 params 重新编码为 JSON
jsonData, err := json.Marshal(params)
if err != nil {
return nil, fmt.Errorf("无法编码 params: %v", err)
}
// 解码到目标结构体
var result T
if err := json.Unmarshal(jsonData, &result); err != nil {
return nil, fmt.Errorf("无法解码为目标结构体: %v", err)
}
return &result, nil
}
```
## /internal/chatlog/wechat/service.go
```go path="/internal/chatlog/wechat/service.go"
package wechat
import (
"context"
"fmt"
"os"
"path/filepath"
"sync"
"time"
"github.com/fsnotify/fsnotify"
"github.com/rs/zerolog/log"
"github.com/sjzar/chatlog/internal/chatlog/ctx"
"github.com/sjzar/chatlog/internal/errors"
"github.com/sjzar/chatlog/internal/wechat"
"github.com/sjzar/chatlog/internal/wechat/decrypt"
"github.com/sjzar/chatlog/pkg/filemonitor"
"github.com/sjzar/chatlog/pkg/util"
)
var (
DebounceTime = 1 * time.Second
MaxWaitTime = 10 * time.Second
)
type Service struct {
ctx *ctx.Context
lastEvents map[string]time.Time
pendingActions map[string]bool
mutex sync.Mutex
fm *filemonitor.FileMonitor
}
func NewService(ctx *ctx.Context) *Service {
return &Service{
ctx: ctx,
lastEvents: make(map[string]time.Time),
pendingActions: make(map[string]bool),
}
}
// GetWeChatInstances returns all running WeChat instances
func (s *Service) GetWeChatInstances() []*wechat.Account {
wechat.Load()
return wechat.GetAccounts()
}
// GetDataKey extracts the encryption key from a WeChat process
func (s *Service) GetDataKey(info *wechat.Account) (string, error) {
if info == nil {
return "", fmt.Errorf("no WeChat instance selected")
}
key, err := info.GetKey(context.Background())
if err != nil {
return "", err
}
return key, nil
}
func (s *Service) StartAutoDecrypt() error {
dbGroup, err := filemonitor.NewFileGroup("wechat", s.ctx.DataDir, `.*\.db$`, []string{"fts"})
if err != nil {
return err
}
dbGroup.AddCallback(s.DecryptFileCallback)
s.fm = filemonitor.NewFileMonitor()
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
}
func (s *Service) StopAutoDecrypt() error {
if s.fm != nil {
if err := s.fm.Stop(); err != nil {
return err
}
}
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
}
s.mutex.Lock()
s.lastEvents[event.Name] = time.Now()
if !s.pendingActions[event.Name] {
s.pendingActions[event.Name] = true
s.mutex.Unlock()
go s.waitAndProcess(event.Name)
} else {
s.mutex.Unlock()
}
return nil
}
func (s *Service) waitAndProcess(dbFile string) {
start := time.Now()
for {
time.Sleep(DebounceTime)
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 {
return err
}
output := filepath.Join(s.ctx.WorkDir, dbFile[len(s.ctx.DataDir):])
if err := util.PrepareDir(filepath.Dir(output)); err != nil {
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 := 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 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
}
}
return nil
}
```
## /internal/errors/errors.go
```go path="/internal/errors/errors.go"
package errors
import (
"errors"
"fmt"
"net/http"
"runtime"
"strings"
"github.com/gin-gonic/gin"
)
type Error struct {
Message string `json:"message"` // 错误消息
Cause error `json:"-"` // 原始错误
Code int `json:"-"` // HTTP Code
Stack []string `json:"-"` // 错误堆栈
}
func (e *Error) Error() string {
if e.Cause != nil {
return fmt.Sprintf("%s: %v", e.Message, e.Cause)
}
return fmt.Sprintf("%s", e.Message)
}
func (e *Error) String() string {
return e.Error()
}
func (e *Error) Unwrap() error {
return e.Cause
}
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,
Cause: cause,
Code: code,
}
}
func Newf(cause error, code int, format string, args ...interface{}) *Error {
return &Error{
Message: fmt.Sprintf(format, args...),
Cause: cause,
Code: code,
}
}
func Wrap(err error, message string, code int) *Error {
if err == nil {
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)
}
func GetCode(err error) int {
if err == nil {
return http.StatusOK
}
var appErr *Error
if errors.As(err, &appErr) {
return appErr.Code
}
return http.StatusInternalServerError
}
func RootCause(err error) error {
for err != nil {
unwrapped := errors.Unwrap(err)
if unwrapped == nil {
return err
}
err = unwrapped
}
return err
}
func Err(c *gin.Context, err error) {
if appErr, ok := err.(*Error); ok {
c.JSON(appErr.Code, appErr.Error())
return
}
c.JSON(http.StatusInternalServerError, err.Error())
}
```
## /internal/errors/http_errors.go
```go path="/internal/errors/http_errors.go"
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")
}
```
## /internal/errors/middleware.go
```go path="/internal/errors/middleware.go"
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()
}
}
```
## /internal/errors/os_errors.go
```go path="/internal/errors/os_errors.go"
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()
}
```
## /internal/errors/wechat_errors.go
```go path="/internal/errors/wechat_errors.go"
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()
}
```
## /internal/errors/wechatdb_errors.go
```go path="/internal/errors/wechatdb_errors.go"
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()
}
```
## /internal/mcp/error.go
```go path="/internal/mcp/error.go"
package mcp
import (
"fmt"
)
// enum ErrorCode {
// // Standard JSON-RPC error codes
// ParseError = -32700,
// InvalidRequest = -32600,
// MethodNotFound = -32601,
// InvalidParams = -32602,
// InternalError = -32603
// }
// Error
type Error struct {
Code int `json:"code"`
Message string `json:"message"`
Data interface{} `json:"data,omitempty"`
}
var (
ErrParseError = &Error{Code: -32700, Message: "Parse error"}
ErrInvalidRequest = &Error{Code: -32600, Message: "Invalid Request"}
ErrMethodNotFound = &Error{Code: -32601, Message: "Method not found"}
ErrInvalidParams = &Error{Code: -32602, Message: "Invalid params"}
ErrInternalError = &Error{Code: -32603, Message: "Internal error"}
ErrInvalidSessionID = &Error{Code: 400, Message: "Invalid session ID"}
ErrSessionNotFound = &Error{Code: 404, Message: "Could not find session"}
ErrTooManyRequests = &Error{Code: 429, Message: "Too many requests"}
)
func (e *Error) Error() string {
return fmt.Sprintf("%d: %s", e.Code, e.Message)
}
func (e *Error) JsonRPC() Response {
return Response{
JsonRPC: JsonRPCVersion,
Error: e,
}
}
func NewErrorResponse(id interface{}, code int, err error) *Response {
return &Response{
JsonRPC: JsonRPCVersion,
ID: id,
Error: &Error{
Code: code,
Message: err.Error(),
},
}
}
```
## /internal/mcp/initialize.go
```go path="/internal/mcp/initialize.go"
package mcp
const (
MethodInitialize = "initialize"
MethodPing = "ping"
ProtocolVersion = "2024-11-05"
)
// {
// "method": "initialize",
// "params": {
// "protocolVersion": "2024-11-05",
// "capabilities": {
// "sampling": {},
// "roots": {
// "listChanged": true
// }
// },
// "clientInfo": {
// "name": "mcp-inspector",
// "version": "0.0.1"
// }
// },
// "jsonrpc": "2.0",
// "id": 0
// }
type InitializeRequest struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities M `json:"capabilities"`
ClientInfo *ClientInfo `json:"clientInfo"`
}
type ClientInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
// {
// "jsonrpc": "2.0",
// "id": 0,
// "result": {
// "protocolVersion": "2024-11-05",
// "capabilities": {
// "experimental": {},
// "prompts": {
// "listChanged": false
// },
// "resources": {
// "subscribe": false,
// "listChanged": false
// },
// "tools": {
// "listChanged": false
// }
// },
// "serverInfo": {
// "name": "weather",
// "version": "1.4.1"
// }
// }
// }
type InitializeResponse struct {
ProtocolVersion string `json:"protocolVersion"`
Capabilities M `json:"capabilities"`
ServerInfo ServerInfo `json:"serverInfo"`
}
type ServerInfo struct {
Name string `json:"name"`
Version string `json:"version"`
}
var DefaultCapabilities = M{
"experimental": M{},
"prompts": M{"listChanged": false},
"resources": M{"subscribe": false, "listChanged": false},
"tools": M{"listChanged": false},
}
```
## /internal/mcp/jsonrpc.go
```go path="/internal/mcp/jsonrpc.go"
package mcp
const (
JsonRPCVersion = "2.0"
)
// Documents: https://modelcontextprotocol.io/docs/concepts/transports
// Request
//
// {
// jsonrpc: "2.0",
// id: number | string,
// method: string,
// params?: object
// }
type Request struct {
JsonRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
}
// Response
//
// {
// jsonrpc: "2.0",
// id: number | string,
// result?: object,
// error?: {
// code: number,
// message: string,
// data?: unknown
// }
// }
type Response struct {
JsonRPC string `json:"jsonrpc"`
ID interface{} `json:"id"`
Result interface{} `json:"result,omitempty"`
Error *Error `json:"error,omitempty"`
}
func NewResponse(id interface{}, result interface{}) *Response {
return &Response{
JsonRPC: JsonRPCVersion,
ID: id,
Result: result,
}
}
// Notifications
//
// {
// jsonrpc: "2.0",
// method: string,
// params?: object
// }
type Notification struct {
JsonRPC string `json:"jsonrpc"`
Method string `json:"method"`
Params interface{} `json:"params,omitempty"`
}
```
## /internal/mcp/mcp.go
```go path="/internal/mcp/mcp.go"
package mcp
import (
"io"
"net/http"
"sync"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"github.com/rs/zerolog/log"
)
const (
ProcessChanCap = 1000
)
type MCP struct {
sessions map[string]*Session
sessionMu sync.Mutex
ProcessChan chan ProcessCtx
}
func NewMCP() *MCP {
return &MCP{
sessions: make(map[string]*Session),
ProcessChan: make(chan ProcessCtx, ProcessChanCap),
}
}
func (m *MCP) HandleSSE(c *gin.Context) {
id := uuid.New().String()
m.sessionMu.Lock()
m.sessions[id] = NewSession(c, id)
m.sessionMu.Unlock()
c.Stream(func(w io.Writer) bool {
<-c.Request.Context().Done()
return false
})
m.sessionMu.Lock()
delete(m.sessions, id)
m.sessionMu.Unlock()
}
func (m *MCP) GetSession(id string) *Session {
m.sessionMu.Lock()
defer m.sessionMu.Unlock()
return m.sessions[id]
}
func (m *MCP) HandleMessages(c *gin.Context) {
// panic("xxx")
// 啊这, 一个 sessionid 有 3 种写法 session_id, sessionId, sessionid
// 官方 SDK 是 session_id: https://github.com/modelcontextprotocol/python-sdk/blob/c897868/src/mcp/server/sse.py#L98
// 写的是 sessionId: https://github.com/modelcontextprotocol/inspector/blob/aeaf32f/server/src/index.ts#L157
sessionID := c.Query("session_id")
if sessionID == "" {
sessionID = c.Query("sessionId")
}
if sessionID == "" {
sessionID = c.Param("sessionid")
}
if sessionID == "" {
c.JSON(http.StatusBadRequest, ErrInvalidSessionID.JsonRPC())
c.Abort()
return
}
session := m.GetSession(sessionID)
if session == nil {
c.JSON(http.StatusNotFound, ErrSessionNotFound.JsonRPC())
c.Abort()
return
}
var req Request
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, ErrInvalidRequest.JsonRPC())
c.Abort()
return
}
log.Debug().Msgf("session: %s, request: %s", sessionID, req)
select {
case m.ProcessChan <- ProcessCtx{Session: session, Request: &req}:
default:
c.JSON(http.StatusTooManyRequests, ErrTooManyRequests.JsonRPC())
c.Abort()
return
}
c.String(http.StatusAccepted, "Accepted")
}
func (m *MCP) Close() {
close(m.ProcessChan)
}
type ProcessCtx struct {
Session *Session
Request *Request
}
```
## /internal/mcp/prompt.go
```go path="/internal/mcp/prompt.go"
package mcp
// Document: https://modelcontextprotocol.io/docs/concepts/prompts
const (
// Client => Server
MethodPromptsList = "prompts/list"
MethodPromptsGet = "prompts/get"
)
// Prompt
//
// {
// name: string; // Unique identifier for the prompt
// description?: string; // Human-readable description
// arguments?: [ // Optional list of arguments
// {
// name: string; // Argument identifier
// description?: string; // Argument description
// required?: boolean; // Whether argument is required
// }
// ]
// }
type Prompt struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Arguments []PromptArgument `json:"arguments,omitempty"`
}
type PromptArgument struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Required bool `json:"required,omitempty"`
}
// ListPrompts
//
// {
// prompts: [
// {
// name: "analyze-code",
// description: "Analyze code for potential improvements",
// arguments: [
// {
// name: "language",
// description: "Programming language",
// required: true
// }
// ]
// }
// ]
// }
type PromptsListResponse struct {
Prompts []Prompt `json:"prompts"`
}
// Use Prompt
// Request
//
// {
// method: "prompts/get",
// params: {
// name: "analyze-code",
// arguments: {
// language: "python"
// }
// }
// }
//
// Response
//
// {
// description: "Analyze Python code for potential improvements",
// messages: [
// {
// role: "user",
// content: {
// type: "text",
// text: "Please analyze the following Python code for potential improvements:\n\n\`\`\`python\ndef calculate_sum(numbers):\n total = 0\n for num in numbers:\n total = total + num\n return total\n\nresult = calculate_sum([1, 2, 3, 4, 5])\nprint(result)\n\`\`\`"
// }
// }
// ]
// }
type PromptsGetRequest struct {
Name string `json:"name"`
Arguments M `json:"arguments"`
}
type PromptsGetResponse struct {
Description string `json:"description"`
Messages []PromptMessage `json:"messages"`
}
type PromptMessage struct {
Role string `json:"role"`
Content PromptContent `json:"content"`
}
type PromptContent struct {
Type string `json:"type"`
Text string `json:"text,omitempty"`
Resource interface{} `json:"resource,omitempty"` // Resource or ResourceTemplate
}
// {
// "messages": [
// {
// "role": "user",
// "content": {
// "type": "text",
// "text": "Analyze these system logs and the code file for any issues:"
// }
// },
// {
// "role": "user",
// "content": {
// "type": "resource",
// "resource": {
// "uri": "logs://recent?timeframe=1h",
// "text": "[2024-03-14 15:32:11] ERROR: Connection timeout in network.py:127\n[2024-03-14 15:32:15] WARN: Retrying connection (attempt 2/3)\n[2024-03-14 15:32:20] ERROR: Max retries exceeded",
// "mimeType": "text/plain"
// }
// }
// },
// {
// "role": "user",
// "content": {
// "type": "resource",
// "resource": {
// "uri": "file:///path/to/code.py",
// "text": "def connect_to_service(timeout=30):\n retries = 3\n for attempt in range(retries):\n try:\n return establish_connection(timeout)\n except TimeoutError:\n if attempt == retries - 1:\n raise\n time.sleep(5)\n\ndef establish_connection(timeout):\n # Connection implementation\n pass",
// "mimeType": "text/x-python"
// }
// }
// }
// ]
// }
```
## /internal/mcp/resource.go
```go path="/internal/mcp/resource.go"
package mcp
// Document: https://modelcontextprotocol.io/docs/concepts/resources
const (
// Client => Server
MethodResourcesList = "resources/list"
MethodResourcesTemplateList = "resources/templates/list"
MethodResourcesRead = "resources/read"
MethodResourcesSubscribe = "resources/subscribe"
MethodResourcesUnsubscribe = "resources/unsubscribe"
// Server => Client
NotificationResourcesListChanged = "notifications/resources/list_changed"
NofiticationResourcesUpdated = "notifications/resources/updated"
)
// Direct resources
//
// {
// uri: string; // Unique identifier for the resource
// name: string; // Human-readable name
// description?: string; // Optional description
// mimeType?: string; // Optional MIME type
// }
type Resource struct {
URI string `json:"uri"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}
// Resource templates
//
// {
// uriTemplate: string; // URI template following RFC 6570
// name: string; // Human-readable name for this type
// description?: string; // Optional description
// mimeType?: string; // Optional MIME type for all matching resources
// }
type ResourceTemplate struct {
URITemplate string `json:"uriTemplate"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
MimeType string `json:"mimeType,omitempty"`
}
// Reading resources
// {
// contents: [
// {
// uri: string; // The URI of the resource
// mimeType?: string; // Optional MIME type
// // One of:
// text?: string; // For text resources
// blob?: string; // For binary resources (base64 encoded)
// }
// ]
// }
type ReadingResource struct {
Contents []ReadingResourceContent `json:"contents"`
}
type ResourcesReadRequest struct {
URI string `json:"uri"`
}
type ReadingResourceContent struct {
URI string `json:"uri"`
MimeType string `json:"mimeType,omitempty"`
Text string `json:"text,omitempty"`
Blob string `json:"blob,omitempty"`
}
```
## /internal/mcp/session.go
```go path="/internal/mcp/session.go"
package mcp
import (
"encoding/json"
"io"
"github.com/gin-gonic/gin"
)
type Session struct {
id string
w io.Writer
c *ClientInfo
}
func NewSession(c *gin.Context, id string) *Session {
return &Session{
id: id,
w: NewSSEWriter(c, id),
}
}
func (s *Session) Write(p []byte) (n int, err error) {
return s.w.Write(p)
}
func (s *Session) WriteError(req *Request, err error) {
resp := NewErrorResponse(req.ID, 500, err)
b, err := json.Marshal(resp)
if err != nil {
return
}
s.Write(b)
}
func (s *Session) WriteResponse(req *Request, data interface{}) error {
resp := NewResponse(req.ID, data)
b, err := json.Marshal(resp)
if err != nil {
return err
}
s.Write(b)
return nil
}
func (s *Session) SaveClientInfo(c *ClientInfo) {
s.c = c
}
```
## /internal/mcp/sse.go
```go path="/internal/mcp/sse.go"
package mcp
import (
"fmt"
"time"
"github.com/gin-gonic/gin"
)
const (
SSEPingIntervalS = 30
SSEMessageChanCap = 100
SSEContentType = "text/event-stream; charset=utf-8"
)
type SSEWriter struct {
id string
c *gin.Context
}
func NewSSEWriter(c *gin.Context, id string) *SSEWriter {
c.Writer.Header().Set("Content-Type", SSEContentType)
c.Writer.Header().Set("Cache-Control", "no-cache")
c.Writer.Header().Set("Connection", "keep-alive")
c.Writer.Header().Set("Access-Control-Allow-Origin", "*")
c.Writer.Flush()
w := &SSEWriter{
id: id,
c: c,
}
w.WriteEndpoing()
go w.ping()
return w
}
func (w *SSEWriter) Write(p []byte) (n int, err error) {
w.WriteMessage(string(p))
return len(p), nil
}
func (w *SSEWriter) WriteMessage(data string) {
w.WriteEvent("message", data)
}
func (w *SSEWriter) WriteEvent(event string, data string) {
w.c.Writer.WriteString(fmt.Sprintf("event: %s\n", event))
w.c.Writer.WriteString(fmt.Sprintf("data: %s\n\n", data))
w.c.Writer.Flush()
}
func (w *SSEWriter) ping() {
for {
select {
case <-time.After(time.Second * SSEPingIntervalS):
w.writePing()
case <-w.c.Request.Context().Done():
return
}
}
}
// WriteEndpoing
// event: endpoint
// data: /message?sessionId=285d67ee-1c17-40d9-ab03-173d5ff48419
func (w *SSEWriter) WriteEndpoing() {
w.c.Writer.WriteString(fmt.Sprintf("event: endpoint\n"))
w.c.Writer.WriteString(fmt.Sprintf("data: /message?sessionId=%s\n\n", w.id))
w.c.Writer.Flush()
}
// WritePing
// : ping - 2025-03-16 06:41:51.280928+00:00
func (w *SSEWriter) writePing() {
w.c.Writer.WriteString(fmt.Sprintf(": ping - %s\n\n", time.Now().Format("2006-01-02 15:04:05.999999-07:00")))
}
// SSE Session
// 维持一个 SSE 连接的会话
// 会话中包含了 SSE 连接的 ID,事件通道,停止通道
// 事件通道用于发送事件,停止通道用于停止会话
// 需要轮询发送 ping 事件以保持连接
type SSESession struct {
SessionID string
Events map[string]chan string
Stop chan bool
c *gin.Context
}
func NewSSESession(c *gin.Context) *SSESession {
return &SSESession{c: c}
}
func (s *SSESession) SendEvent(event string, data string) {
s.c.SSEvent(event, data)
}
func (s *SSESession) Close() {
close(s.Stop)
}
// Event
// request:
// POST /messages?sesessionId=?
// '{"method":"prompts/list","params":{},"jsonrpc":"2.0","id":3}'
//
// response:
// GET /sse
// event: message
// data: {"jsonrpc":"2.0","id":3,"result":{"prompts":[]}}
// {
// "jsonrpc": "2.0",
// "id": 1,
// "result": {
// "tools": [
// {
// "name": "get_alerts",
// "description": "Get weather alerts for a US state.\n\n Args:\n state: Two-letter US state code (e.g. CA, NY)\n ",
// "inputSchema": {
// "properties": {
// "state": {
// "title": "State",
// "type": "string"
// }
// },
// "required": [
// "state"
// ],
// "title": "get_alertsArguments",
// "type": "object"
// }
// },
// {
// "name": "get_forecast",
// "description": "Get weather forecast for a location.\n\n Args:\n latitude: Latitude of the location\n longitude: Longitude of the location\n ",
// "inputSchema": {
// "properties": {
// "latitude": {
// "title": "Latitude",
// "type": "number"
// },
// "longitude": {
// "title": "Longitude",
// "type": "number"
// }
// },
// "required": [
// "latitude",
// "longitude"
// ],
// "title": "get_forecastArguments",
// "type": "object"
// }
// }
// ]
// }
// }
// PING
```
## /internal/mcp/stdio.go
```go path="/internal/mcp/stdio.go"
package mcp
```
## /internal/mcp/tool.go
```go path="/internal/mcp/tool.go"
package mcp
// Document: https://modelcontextprotocol.io/docs/concepts/tools
const (
// Client => Server
MethodToolsList = "tools/list"
MethodToolsCall = "tools/call"
)
type M map[string]interface{}
// Tool
//
// {
// name: string; // Unique identifier for the tool
// description?: string; // Human-readable description
// inputSchema: { // JSON Schema for the tool's parameters
// type: "object",
// properties: { ... } // Tool-specific parameters
// }
// }
//
// {
// name: "analyze_csv",
// description: "Analyze a CSV file",
// inputSchema: {
// type: "object",
// properties: {
// filepath: { type: "string" },
// operations: {
// type: "array",
// items: {
// enum: ["sum", "average", "count"]
// }
// }
// }
// }
// }
//
// {
// "jsonrpc": "2.0",
// "id": 1,
// "result": {
// "tools": [
// {
// "name": "get_alerts",
// "description": "Get weather alerts for a US state.\n\n Args:\n state: Two-letter US state code (e.g. CA, NY)\n ",
// "inputSchema": {
// "properties": {
// "state": {
// "title": "State",
// "type": "string"
// }
// },
// "required": [
// "state"
// ],
// "title": "get_alertsArguments",
// "type": "object"
// }
// },
// {
// "name": "get_forecast",
// "description": "Get weather forecast for a location.\n\n Args:\n latitude: Latitude of the location\n longitude: Longitude of the location\n ",
// "inputSchema": {
// "properties": {
// "latitude": {
// "title": "Latitude",
// "type": "number"
// },
// "longitude": {
// "title": "Longitude",
// "type": "number"
// }
// },
// "required": [
// "latitude",
// "longitude"
// ],
// "title": "get_forecastArguments",
// "type": "object"
// }
// }
// ]
// }
// }
type Tool struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
InputSchema ToolSchema `json:"inputSchema"`
}
type ToolSchema struct {
Type string `json:"type"`
Properties M `json:"properties"`
Required []string `json:"required,omitempty"`
}
// {
// "method": "tools/call",
// "params": {
// "name": "chatlog",
// "arguments": {
// "start": "2006-11-12",
// "end": "2020-11-20",
// "limit": "50",
// "offset": "6"
// },
// "_meta": {
// "progressToken": 1
// }
// },
// "jsonrpc": "2.0",
// "id": 3
// }
type ToolsCallRequest struct {
Name string `json:"name"`
Arguments M `json:"arguments"`
}
// {
// "jsonrpc": "2.0",
// "id": 2,
// "result": {
// "content": [
// {
// "type": "text",
// "text": "\nEvent: Winter Storm Warning\n"
// }
// ],
// "isError": false
// }
// }
type ToolsCallResponse struct {
Content []Content `json:"content"`
IsError bool `json:"isError"`
}
type Content struct {
Type string `json:"type"`
Text string `json:"text"`
}
```
## /internal/model/chatroom.go
```go path="/internal/model/chatroom.go"
package model
import (
"github.com/sjzar/chatlog/internal/model/wxproto"
"google.golang.org/protobuf/proto"
)
type ChatRoom struct {
Name string `json:"name"`
Owner string `json:"owner"`
Users []ChatRoomUser `json:"users"`
// Extra From Contact
Remark string `json:"remark"`
NickName string `json:"nickName"`
User2DisplayName map[string]string `json:"-"`
}
type ChatRoomUser struct {
UserName string `json:"userName"`
DisplayName string `json:"displayName"`
}
// CREATE TABLE ChatRoom(
// ChatRoomName TEXT PRIMARY KEY,
// UserNameList TEXT,
// DisplayNameList TEXT,
// ChatRoomFlag int Default 0,
// Owner INTEGER DEFAULT 0,
// IsShowName INTEGER DEFAULT 0,
// SelfDisplayName TEXT,
// Reserved1 INTEGER DEFAULT 0,
// Reserved2 TEXT,
// Reserved3 INTEGER DEFAULT 0,
// Reserved4 TEXT,
// Reserved5 INTEGER DEFAULT 0,
// Reserved6 TEXT,
// RoomData BLOB,
// Reserved7 INTEGER DEFAULT 0,
// Reserved8 TEXT
// )
type ChatRoomV3 struct {
ChatRoomName string `json:"ChatRoomName"`
Reserved2 string `json:"Reserved2"` // Creator
RoomData []byte `json:"RoomData"`
// // 非关键信息,暂时忽略
// UserNameList string `json:"UserNameList"`
// DisplayNameList string `json:"DisplayNameList"`
// ChatRoomFlag int `json:"ChatRoomFlag"`
// Owner int `json:"Owner"`
// IsShowName int `json:"IsShowName"`
// SelfDisplayName string `json:"SelfDisplayName"`
// Reserved1 int `json:"Reserved1"`
// Reserved3 int `json:"Reserved3"`
// Reserved4 string `json:"Reserved4"`
// Reserved5 int `json:"Reserved5"`
// Reserved6 string `json:"Reserved6"`
// Reserved7 int `json:"Reserved7"`
// Reserved8 string `json:"Reserved8"`
}
func (c *ChatRoomV3) Wrap() *ChatRoom {
var users []ChatRoomUser
if len(c.RoomData) != 0 {
users = ParseRoomData(c.RoomData)
}
user2DisplayName := make(map[string]string, len(users))
for _, user := range users {
if user.DisplayName != "" {
user2DisplayName[user.UserName] = user.DisplayName
}
}
return &ChatRoom{
Name: c.ChatRoomName,
Owner: c.Reserved2,
Users: users,
User2DisplayName: user2DisplayName,
}
}
func ParseRoomData(b []byte) (users []ChatRoomUser) {
var pbMsg wxproto.RoomData
if err := proto.Unmarshal(b, &pbMsg); err != nil {
return
}
if pbMsg.Users == nil {
return
}
users = make([]ChatRoomUser, 0, len(pbMsg.Users))
for _, user := range pbMsg.Users {
u := ChatRoomUser{UserName: user.UserName}
if user.DisplayName != nil {
u.DisplayName = *user.DisplayName
}
users = append(users, u)
}
return users
}
func (c *ChatRoom) DisplayName() string {
switch {
case c.Remark != "":
return c.Remark
case c.NickName != "":
return c.NickName
}
return ""
}
```
## /internal/model/chatroom_darwinv3.go
```go path="/internal/model/chatroom_darwinv3.go"
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,
}
}
```
## /internal/model/chatroom_v4.go
```go path="/internal/model/chatroom_v4.go"
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,
}
}
```
## /internal/model/contact.go
```go path="/internal/model/contact.go"
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 ""
}
```
## /internal/model/contact_darwinv3.go
```go path="/internal/model/contact_darwinv3.go"
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,
}
}
```
## /internal/model/contact_v4.go
```go path="/internal/model/contact_v4.go"
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,
}
}
```
## /internal/model/media.go
```go path="/internal/model/media.go"
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,
}
}
```
## /internal/model/media_darwinv3.go
```go path="/internal/model/media_darwinv3.go"
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,
}
}
```
## /internal/model/media_v4.go
```go path="/internal/model/media_v4.go"
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,
}
}
```
## /internal/model/mediamessage.go
```go path="/internal/model/mediamessage.go"
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
}
```
## /internal/model/message.go
```go path="/internal/model/message.go"
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)
}
}
```
## /internal/model/message_darwinv3.go
```go path="/internal/model/message_darwinv3.go"
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
}
```
## /internal/model/message_v3.go
```go path="/internal/model/message_v3.go"
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
}
```
## /internal/model/message_v4.go
```go path="/internal/model/message_v4.go"
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
}
```
## /internal/model/session.go
```go path="/internal/model/session.go"
package model
import (
"strings"
"time"
)
type Session struct {
UserName string `json:"userName"`
NOrder int `json:"nOrder"`
NickName string `json:"nickName"`
Content string `json:"content"`
NTime time.Time `json:"nTime"`
}
// CREATE TABLE Session(
// strUsrName TEXT PRIMARY KEY,
// nOrder INT DEFAULT 0,
// nUnReadCount INTEGER DEFAULT 0,
// parentRef TEXT,
// Reserved0 INTEGER DEFAULT 0,
// Reserved1 TEXT,
// strNickName TEXT,
// nStatus INTEGER,
// nIsSend INTEGER,
// strContent TEXT,
// nMsgType INTEGER,
// nMsgLocalID INTEGER,
// nMsgStatus INTEGER,
// nTime INTEGER,
// editContent TEXT,
// othersAtMe INT,
// Reserved2 INTEGER DEFAULT 0,
// Reserved3 TEXT,
// Reserved4 INTEGER DEFAULT 0,
// Reserved5 TEXT,
// bytesXml BLOB
// )
type SessionV3 struct {
StrUsrName string `json:"strUsrName"`
NOrder int `json:"nOrder"`
StrNickName string `json:"strNickName"`
StrContent string `json:"strContent"`
NTime int64 `json:"nTime"`
// NUnReadCount int `json:"nUnReadCount"`
// ParentRef string `json:"parentRef"`
// Reserved0 int `json:"Reserved0"`
// Reserved1 string `json:"Reserved1"`
// NStatus int `json:"nStatus"`
// NIsSend int `json:"nIsSend"`
// NMsgType int `json:"nMsgType"`
// NMsgLocalID int `json:"nMsgLocalID"`
// NMsgStatus int `json:"nMsgStatus"`
// EditContent string `json:"editContent"`
// OthersAtMe int `json:"othersAtMe"`
// Reserved2 int `json:"Reserved2"`
// Reserved3 string `json:"Reserved3"`
// Reserved4 int `json:"Reserved4"`
// Reserved5 string `json:"Reserved5"`
// BytesXml string `json:"bytesXml"`
}
func (s *SessionV3) Wrap() *Session {
return &Session{
UserName: s.StrUsrName,
NOrder: s.NOrder,
NickName: s.StrNickName,
Content: s.StrContent,
NTime: time.Unix(int64(s.NTime), 0),
}
}
func (s *Session) PlainText(limit int) string {
buf := strings.Builder{}
buf.WriteString(s.NickName)
buf.WriteString("(")
buf.WriteString(s.UserName)
buf.WriteString(") ")
buf.WriteString(s.NTime.Format("2006-01-02 15:04:05"))
buf.WriteString("\n")
if limit > 0 {
if len(s.Content) > limit {
buf.WriteString(s.Content[:limit])
buf.WriteString(" <...>")
} else {
buf.WriteString(s.Content)
}
}
buf.WriteString("\n")
return buf.String()
}
```
## /internal/model/session_darwinv3.go
```go path="/internal/model/session_darwinv3.go"
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),
}
}
```
## /internal/model/session_v4.go
```go path="/internal/model/session_v4.go"
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),
}
}
```
## /internal/model/wxproto/bytesextra.pb.go
```go path="/internal/model/wxproto/bytesextra.pb.go"
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.5
// protoc v5.29.3
// source: bytesextra.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 BytesExtraHeader struct {
state protoimpl.MessageState `protogen:"open.v1"`
Field1 int32 `protobuf:"varint,1,opt,name=field1,proto3" json:"field1,omitempty"`
Field2 int32 `protobuf:"varint,2,opt,name=field2,proto3" json:"field2,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BytesExtraHeader) Reset() {
*x = BytesExtraHeader{}
mi := &file_bytesextra_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BytesExtraHeader) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BytesExtraHeader) ProtoMessage() {}
func (x *BytesExtraHeader) ProtoReflect() protoreflect.Message {
mi := &file_bytesextra_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 BytesExtraHeader.ProtoReflect.Descriptor instead.
func (*BytesExtraHeader) Descriptor() ([]byte, []int) {
return file_bytesextra_proto_rawDescGZIP(), []int{0}
}
func (x *BytesExtraHeader) GetField1() int32 {
if x != nil {
return x.Field1
}
return 0
}
func (x *BytesExtraHeader) GetField2() int32 {
if x != nil {
return x.Field2
}
return 0
}
type BytesExtraItem struct {
state protoimpl.MessageState `protogen:"open.v1"`
Type int32 `protobuf:"varint,1,opt,name=type,proto3" json:"type,omitempty"`
Value string `protobuf:"bytes,2,opt,name=value,proto3" json:"value,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BytesExtraItem) Reset() {
*x = BytesExtraItem{}
mi := &file_bytesextra_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BytesExtraItem) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BytesExtraItem) ProtoMessage() {}
func (x *BytesExtraItem) ProtoReflect() protoreflect.Message {
mi := &file_bytesextra_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 BytesExtraItem.ProtoReflect.Descriptor instead.
func (*BytesExtraItem) Descriptor() ([]byte, []int) {
return file_bytesextra_proto_rawDescGZIP(), []int{1}
}
func (x *BytesExtraItem) GetType() int32 {
if x != nil {
return x.Type
}
return 0
}
func (x *BytesExtraItem) GetValue() string {
if x != nil {
return x.Value
}
return ""
}
type BytesExtra struct {
state protoimpl.MessageState `protogen:"open.v1"`
Header *BytesExtraHeader `protobuf:"bytes,1,opt,name=header,proto3" json:"header,omitempty"`
Items []*BytesExtraItem `protobuf:"bytes,3,rep,name=items,proto3" json:"items,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *BytesExtra) Reset() {
*x = BytesExtra{}
mi := &file_bytesextra_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *BytesExtra) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*BytesExtra) ProtoMessage() {}
func (x *BytesExtra) ProtoReflect() protoreflect.Message {
mi := &file_bytesextra_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 BytesExtra.ProtoReflect.Descriptor instead.
func (*BytesExtra) Descriptor() ([]byte, []int) {
return file_bytesextra_proto_rawDescGZIP(), []int{2}
}
func (x *BytesExtra) GetHeader() *BytesExtraHeader {
if x != nil {
return x.Header
}
return nil
}
func (x *BytesExtra) GetItems() []*BytesExtraItem {
if x != nil {
return x.Items
}
return nil
}
var File_bytesextra_proto protoreflect.FileDescriptor
var file_bytesextra_proto_rawDesc = string([]byte{
0x0a, 0x10, 0x62, 0x79, 0x74, 0x65, 0x73, 0x65, 0x78, 0x74, 0x72, 0x61, 0x2e, 0x70, 0x72, 0x6f,
0x74, 0x6f, 0x12, 0x0c, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66,
0x22, 0x42, 0x0a, 0x10, 0x42, 0x79, 0x74, 0x65, 0x73, 0x45, 0x78, 0x74, 0x72, 0x61, 0x48, 0x65,
0x61, 0x64, 0x65, 0x72, 0x12, 0x16, 0x0a, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x18, 0x01,
0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x31, 0x12, 0x16, 0x0a, 0x06,
0x66, 0x69, 0x65, 0x6c, 0x64, 0x32, 0x18, 0x02, 0x20, 0x01, 0x28, 0x05, 0x52, 0x06, 0x66, 0x69,
0x65, 0x6c, 0x64, 0x32, 0x22, 0x3a, 0x0a, 0x0e, 0x42, 0x79, 0x74, 0x65, 0x73, 0x45, 0x78, 0x74,
0x72, 0x61, 0x49, 0x74, 0x65, 0x6d, 0x12, 0x12, 0x0a, 0x04, 0x74, 0x79, 0x70, 0x65, 0x18, 0x01,
0x20, 0x01, 0x28, 0x05, 0x52, 0x04, 0x74, 0x79, 0x70, 0x65, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61,
0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65,
0x22, 0x78, 0x0a, 0x0a, 0x42, 0x79, 0x74, 0x65, 0x73, 0x45, 0x78, 0x74, 0x72, 0x61, 0x12, 0x36,
0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e,
0x2e, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x42, 0x79,
0x74, 0x65, 0x73, 0x45, 0x78, 0x74, 0x72, 0x61, 0x48, 0x65, 0x61, 0x64, 0x65, 0x72, 0x52, 0x06,
0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x12, 0x32, 0x0a, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x18,
0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x61, 0x70, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74,
0x6f, 0x62, 0x75, 0x66, 0x2e, 0x42, 0x79, 0x74, 0x65, 0x73, 0x45, 0x78, 0x74, 0x72, 0x61, 0x49,
0x74, 0x65, 0x6d, 0x52, 0x05, 0x69, 0x74, 0x65, 0x6d, 0x73, 0x42, 0x0b, 0x5a, 0x09, 0x2e, 0x3b,
0x77, 0x78, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33,
})
var (
file_bytesextra_proto_rawDescOnce sync.Once
file_bytesextra_proto_rawDescData []byte
)
func file_bytesextra_proto_rawDescGZIP() []byte {
file_bytesextra_proto_rawDescOnce.Do(func() {
file_bytesextra_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_bytesextra_proto_rawDesc), len(file_bytesextra_proto_rawDesc)))
})
return file_bytesextra_proto_rawDescData
}
var file_bytesextra_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_bytesextra_proto_goTypes = []any{
(*BytesExtraHeader)(nil), // 0: app.protobuf.BytesExtraHeader
(*BytesExtraItem)(nil), // 1: app.protobuf.BytesExtraItem
(*BytesExtra)(nil), // 2: app.protobuf.BytesExtra
}
var file_bytesextra_proto_depIdxs = []int32{
0, // 0: app.protobuf.BytesExtra.header:type_name -> app.protobuf.BytesExtraHeader
1, // 1: app.protobuf.BytesExtra.items:type_name -> app.protobuf.BytesExtraItem
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_bytesextra_proto_init() }
func file_bytesextra_proto_init() {
if File_bytesextra_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_bytesextra_proto_rawDesc), len(file_bytesextra_proto_rawDesc)),
NumEnums: 0,
NumMessages: 3,
NumExtensions: 0,
NumServices: 0,
},
GoTypes: file_bytesextra_proto_goTypes,
DependencyIndexes: file_bytesextra_proto_depIdxs,
MessageInfos: file_bytesextra_proto_msgTypes,
}.Build()
File_bytesextra_proto = out.File
file_bytesextra_proto_goTypes = nil
file_bytesextra_proto_depIdxs = nil
}
```
## /internal/model/wxproto/bytesextra.proto
```proto path="/internal/model/wxproto/bytesextra.proto"
syntax = "proto3";
package app.protobuf;
option go_package=".;wxproto";
message BytesExtraHeader {
int32 field1 = 1;
int32 field2 = 2;
}
message BytesExtraItem {
int32 type = 1;
string value = 2;
}
message BytesExtra {
BytesExtraHeader header = 1;
repeated BytesExtraItem items = 3;
}
```
## /internal/model/wxproto/packedinfo.pb.go
```go path="/internal/model/wxproto/packedinfo.pb.go"
// 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
}
```
## /internal/model/wxproto/packedinfo.proto
```proto path="/internal/model/wxproto/packedinfo.proto"
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 哈希
}
```
The content has been capped at 50000 tokens, and files over NaN bytes have been omitted. The user could consider applying other filters to refine the result. The better and more specific the context, the better the LLM can follow instructions. If the context seems verbose, the user can refine the filter using uithub. Thank you for using https://uithub.com - Perfect LLM context for any GitHub repo.